diff --git a/locales/en-US.yml b/locales/en-US.yml index a6a8a2110..10e0e4c65 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -828,6 +828,8 @@ setTag: "Set tag" addTag: "Add tag" removeTag: "Remove tag" externalCssSnippets: "Some CSS snippets for your inspiration (not managed by FoundKey)" +oauthErrorGoBack: "An error happened while trying to authenticate a 3rd party app.\ + \ Please go back and try again." _emailUnavailable: used: "This email address is already being used" format: "The format of this email address is invalid" diff --git a/packages/backend/src/server/api/endpoints/auth/session/generate.ts b/packages/backend/src/server/api/endpoints/auth/session/generate.ts index eeb51abc6..e1b498a49 100644 --- a/packages/backend/src/server/api/endpoints/auth/session/generate.ts +++ b/packages/backend/src/server/api/endpoints/auth/session/generate.ts @@ -23,6 +23,19 @@ export const meta = { optional: false, nullable: false, format: 'url', }, + // stuff that auth/session/show would respond with + id: { + type: 'string', + description: 'The ID of the authentication session. Same as returned by `auth/session/show`.', + optional: false, nullable: false, + format: 'id', + }, + app: { + type: 'object', + description: 'The App requesting permissions. Same as returned by `auth/session/show`.', + optional: false, nullable: false, + ref: 'App', + }, }, }, @@ -30,17 +43,27 @@ export const meta = { } as const; export const paramDef = { - type: 'object', - properties: { - appSecret: { type: 'string' }, - }, - required: ['appSecret'], + oneOf: [{ + type: 'object', + properties: { + clientId: { type: 'string' }, + }, + required: ['clientId'] + }, { + type: 'object', + properties: { + appSecret: { type: 'string' }, + }, + required: ['appSecret'], + }], } as const; // eslint-disable-next-line import/no-default-export export default define(meta, paramDef, async (ps) => { // Lookup app - const app = await Apps.findOneBy({ + const app = await Apps.findOneBy(ps.clientId ? { + id: ps.clientId, + } : { secret: ps.appSecret, }); @@ -50,10 +73,11 @@ export default define(meta, paramDef, async (ps) => { // Generate token const token = uuid(); + const id = genId(); // Create session token document const doc = await AuthSessions.insert({ - id: genId(), + id, createdAt: new Date(), appId: app.id, token, @@ -62,5 +86,7 @@ export default define(meta, paramDef, async (ps) => { return { token: doc.token, url: `${config.authUrl}/${doc.token}`, + id, + app: await Apps.pack(app), }; }); diff --git a/packages/client/src/pages/auth.vue b/packages/client/src/pages/auth.vue index 3b3ff7bac..ac9a3e051 100644 --- a/packages/client/src/pages/auth.vue +++ b/packages/client/src/pages/auth.vue @@ -6,7 +6,7 @@ ref="form" class="form" :session="session" - @denied="state = 'denied'" + @denied="denied" @accepted="accepted" />
@@ -20,6 +20,9 @@

{{ i18n.ts.somethingHappened }}

+
+

{{ i18n.ts.oauthErrorGoBack }}

+
@@ -37,43 +40,115 @@ import { i18n } from '@/i18n'; import { query, appendQuery } from '@/scripts/url'; const props = defineProps<{ - token: string; + token?: string; }>(); -let state: 'fetching' | 'waiting' | 'denied' | 'accepted' | 'fetch-session-error' = $ref('fetching'); +let state: 'fetching' | 'waiting' | 'denied' | 'accepted' | 'fetch-session-error' | 'oauth-error' = $ref('fetching'); let session = $ref(null); -onMounted(() => { +// if this is an OAuth request, will contain the respective parameters +let oauth: { state: string | null, callback: string } | null = null; + +onMounted(async () => { if (!$i) return; - // Fetch session - os.api('auth/session/show', { - token: props.token, - }).then(fetchedSession => { - session = fetchedSession; + // detect whether this is actual OAuth or "legacy" auth + const params = new URLSearchParams(location.search); + if (params.get('response_type') === 'code') { + // OAuth request detected! - // 既に連携していた場合 - if (session.app.isAuthorized) { - os.api('auth/accept', { - token: session.token, - }).then(() => { - this.accepted(); - }); - } else { - state = 'waiting'; + // as a kind of hack, we first have to start the session for the OAuth client + const clientId = params.get('client_id'); + if (!clientId) { + state = 'fetch-session-error'; + return; } - }).catch(() => { + + session = await os.api('auth/session/generate', { + clientId, + }).catch(e => { + const response = { + error: 'server_error', + ...(oauth.state ? { state: oauth.state } : {}), + }; + // try to determine the cause of the error + if (e.code === 'NO_SUCH_APP') { + response.error = 'invalid_request'; + response.error_description = 'unknown client_id'; + } else if (e.message) { + response.error_description = e.message; + } + + if (params.has('redirect_uri')) { + location.href = appendQuery(params.get('redirect_uri'), query(response)); + } else { + state = 'oauth-error'; + } + }); + + oauth = { + state: params.get('state'), + callback: params.get('redirect_uri') ?? session.app.callbackUrl, + }; + } else if (!props.token) { state = 'fetch-session-error'; - }); + } else { + session = await os.api('auth/session/show', { + token: props.token, + }).catch(() => { + state = 'fetch-session-error'; + }); + } + + // abort if an error occurred + if (['fetch-session-error', 'oauth-error'].includes(state)) return; + + // check whether the user already authorized the app earlier + if (session.app.isAuthorized) { + // already authorized, move on through! + os.api('auth/accept', { + token: session.token, + }).then(() => { + accepted(); + }); + } else { + // user still has to give consent + state = 'waiting'; + } }); function accepted(): void { state = 'accepted'; - if (session.app.callbackUrl) { + if (oauth) { + // redirect with authorization token + const params = { + code: session.token, + ...(oauth.state ? { state: oauth.state } : {}), + }; + + location.href = appendQuery(oauth.callback, query(params)); + } else if (session.app.callbackUrl) { + // do whatever the legacy auth did location.href = appendQuery(session.app.callbackUrl, query({ token: session.token })); } } +function denied(): void { + state = 'denied'; + if (oauth) { + // redirect with error code + const params = { + error: 'access_denied', + error_description: 'The user denied permission.', + ...(oauth.state ? { state: oauth.state } : {}), + }; + + location.href = appendQuery(oauth.callback, query(params)); + } else { + // legacy auth didn't do anything in this case... + } +} + function onLogin(res): void { login(res.i); } diff --git a/packages/client/src/router.ts b/packages/client/src/router.ts index 6d3011688..791076e71 100644 --- a/packages/client/src/router.ts +++ b/packages/client/src/router.ts @@ -94,6 +94,9 @@ export const routes = [{ }, { path: '/preview', component: page(() => import('./pages/preview.vue')), +}, { + path: '/auth', + component: page(() => import('./pages/auth.vue')), }, { path: '/auth/:token', component: page(() => import('./pages/auth.vue')),