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"
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"

View file

@ -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),
};
});

View file

@ -6,7 +6,7 @@
ref="form"
class="form"
:session="session"
@denied="state = 'denied'"
@denied="denied"
@accepted="accepted"
/>
<div v-else-if="state == 'denied'" class="denied">
@ -20,6 +20,9 @@
<div v-else-if="state == 'fetch-session-error'" class="error">
<p>{{ i18n.ts.somethingHappened }}</p>
</div>
<div v-else-if="state == 'oauth-error'" class="error">
<p>{{ i18n.ts.oauthErrorGoBack }}</p>
</div>
</div>
<div v-else class="signin">
<MkSignin @login="onLogin"/>
@ -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);
}

View file

@ -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')),