From a13e956af00b0acdafcc22d4067b63459bc03bc4 Mon Sep 17 00:00:00 2001 From: Johann150 Date: Sun, 16 Oct 2022 00:46:13 +0200 Subject: [PATCH] make authorization token granting OAuth 2.0 compatible This is basically a shim on top of the existing API. Instead of the 3rd party, the web UI generates the authorization session. The data that the API returns is slightly adjusted so that only one API call is necessary instead of two. --- locales/en-US.yml | 2 + .../api/endpoints/auth/session/generate.ts | 40 ++++-- packages/client/src/pages/auth.vue | 117 ++++++++++++++---- packages/client/src/router.ts | 3 + 4 files changed, 134 insertions(+), 28 deletions(-) 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')),