forked from FoundKeyGang/FoundKey
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:
parent
18cf228f89
commit
a13e956af0
4 changed files with 134 additions and 28 deletions
|
@ -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"
|
||||||
|
|
|
@ -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),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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')),
|
||||||
|
|
Loading…
Reference in a new issue