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.
This commit is contained in:
Johann150 2022-10-16 00:46:13 +02:00 committed by Gitea
parent 18cf228f89
commit a13e956af0
4 changed files with 134 additions and 28 deletions

View file

@ -828,6 +828,8 @@ setTag: "Set tag"
addTag: "Add tag" addTag: "Add tag"
removeTag: "Remove tag" removeTag: "Remove tag"
externalCssSnippets: "Some CSS snippets for your inspiration (not managed by FoundKey)" 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: _emailUnavailable:
used: "This email address is already being used" used: "This email address is already being used"
format: "The format of this email address is invalid" format: "The format of this email address is invalid"

View file

@ -23,6 +23,19 @@ export const meta = {
optional: false, nullable: false, optional: false, nullable: false,
format: 'url', 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; } as const;
export const paramDef = { export const paramDef = {
oneOf: [{
type: 'object',
properties: {
clientId: { type: 'string' },
},
required: ['clientId']
}, {
type: 'object', type: 'object',
properties: { properties: {
appSecret: { type: 'string' }, appSecret: { type: 'string' },
}, },
required: ['appSecret'], required: ['appSecret'],
}],
} as const; } as const;
// eslint-disable-next-line import/no-default-export // eslint-disable-next-line import/no-default-export
export default define(meta, paramDef, async (ps) => { export default define(meta, paramDef, async (ps) => {
// Lookup app // Lookup app
const app = await Apps.findOneBy({ const app = await Apps.findOneBy(ps.clientId ? {
id: ps.clientId,
} : {
secret: ps.appSecret, secret: ps.appSecret,
}); });
@ -50,10 +73,11 @@ export default define(meta, paramDef, async (ps) => {
// Generate token // Generate token
const token = uuid(); const token = uuid();
const id = genId();
// Create session token document // Create session token document
const doc = await AuthSessions.insert({ const doc = await AuthSessions.insert({
id: genId(), id,
createdAt: new Date(), createdAt: new Date(),
appId: app.id, appId: app.id,
token, token,
@ -62,5 +86,7 @@ export default define(meta, paramDef, async (ps) => {
return { return {
token: doc.token, token: doc.token,
url: `${config.authUrl}/${doc.token}`, url: `${config.authUrl}/${doc.token}`,
id,
app: await Apps.pack(app),
}; };
}); });

View file

@ -6,7 +6,7 @@
ref="form" ref="form"
class="form" class="form"
:session="session" :session="session"
@denied="state = 'denied'" @denied="denied"
@accepted="accepted" @accepted="accepted"
/> />
<div v-else-if="state == 'denied'" class="denied"> <div v-else-if="state == 'denied'" class="denied">
@ -20,6 +20,9 @@
<div v-else-if="state == 'fetch-session-error'" class="error"> <div v-else-if="state == 'fetch-session-error'" class="error">
<p>{{ i18n.ts.somethingHappened }}</p> <p>{{ i18n.ts.somethingHappened }}</p>
</div> </div>
<div v-else-if="state == 'oauth-error'" class="error">
<p>{{ i18n.ts.oauthErrorGoBack }}</p>
</div>
</div> </div>
<div v-else class="signin"> <div v-else class="signin">
<MkSignin @login="onLogin"/> <MkSignin @login="onLogin"/>
@ -37,43 +40,115 @@ import { i18n } from '@/i18n';
import { query, appendQuery } from '@/scripts/url'; import { query, appendQuery } from '@/scripts/url';
const props = defineProps<{ 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); 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; if (!$i) return;
// Fetch session // detect whether this is actual OAuth or "legacy" auth
os.api('auth/session/show', { const params = new URLSearchParams(location.search);
token: props.token, if (params.get('response_type') === 'code') {
}).then(fetchedSession => { // OAuth request detected!
session = fetchedSession;
// // as a kind of hack, we first have to start the session for the OAuth client
if (session.app.isAuthorized) { const clientId = params.get('client_id');
os.api('auth/accept', { if (!clientId) {
token: session.token, state = 'fetch-session-error';
}).then(() => { return;
this.accepted();
});
} else {
state = 'waiting';
} }
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(() => { }).catch(() => {
state = 'fetch-session-error'; 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 { function accepted(): void {
state = 'accepted'; 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 })); 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 { function onLogin(res): void {
login(res.i); login(res.i);
} }

View file

@ -94,6 +94,9 @@ export const routes = [{
}, { }, {
path: '/preview', path: '/preview',
component: page(() => import('./pages/preview.vue')), component: page(() => import('./pages/preview.vue')),
}, {
path: '/auth',
component: page(() => import('./pages/auth.vue')),
}, { }, {
path: '/auth/:token', path: '/auth/:token',
component: page(() => import('./pages/auth.vue')), component: page(() => import('./pages/auth.vue')),