Compare commits

...

7 Commits
main ... oauth

Author SHA1 Message Date
Johann150 3c9422dcb0
docs: read scope descriptions from locale strings 2022-10-19 15:26:54 +02:00
Johann150 d16c717c3b
client: fix auth page layout
This also includes better rendering when no permissions are requested.

Changelog: Fixed
2022-10-19 15:26:54 +02:00
Johann150 20d8f6cd36
server: add missing auth/deny endpoint
This endpoint is hinted at in the client, but is not actually defined
in the backend. This commit defines it.
2022-10-19 15:26:54 +02:00
Johann150 cb4a7f1981
expire AuthSessions after 15 min 2022-10-19 15:26:53 +02:00
Johann150 f64134c717
update OpenAPI docs to OAuth 2022-10-19 15:26:53 +02:00
Johann150 f975f2d8aa
add API route for OAuth access token retrieval 2022-10-19 15:26:53 +02:00
Johann150 929c0a895b
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.
2022-10-19 15:26:53 +02:00
12 changed files with 382 additions and 98 deletions

View File

@ -929,6 +929,10 @@ 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."
appAuthorization: "App authorization"
noPermissionsRequested: "(No permissions requested.)"
_emailUnavailable:
used: "This email address is already being used"
format: "The format of this email address is invalid"
@ -1247,38 +1251,37 @@ _2fa:
\ authentication via hardware security keys that support FIDO2 to further secure\
\ your account."
_permissions:
"read:account": "View your account information"
"write:account": "Edit your account information"
"read:blocks": "View your list of blocked users"
"write:blocks": "Edit your list of blocked users"
"read:drive": "Access your Drive files and folders"
"write:drive": "Edit or delete your Drive files and folders"
"read:favorites": "View your list of favorites"
"write:favorites": "Edit your list of favorites"
"read:following": "View information on who you follow"
"write:following": "Follow or unfollow other accounts"
"read:messaging": "View your chats"
"write:messaging": "Compose or delete chat messages"
"read:mutes": "View your list of muted users"
"write:mutes": "Edit your list of muted users"
"write:notes": "Compose or delete notes"
"read:notifications": "View your notifications"
"write:notifications": "Manage your notifications"
"read:reactions": "View your reactions"
"write:reactions": "Edit your reactions"
"write:votes": "Vote on a poll"
"read:pages": "View your pages"
"write:pages": "Edit or delete your pages"
"read:page-likes": "View your likes on pages"
"write:page-likes": "Edit your likes on pages"
"read:user-groups": "View your user groups"
"write:user-groups": "Edit or delete your user groups"
"read:channels": "View your channels"
"write:channels": "Edit your channels"
"read:gallery": "View your gallery"
"write:gallery": "Edit your gallery"
"read:gallery-likes": "View your list of liked gallery posts"
"write:gallery-likes": "Edit your list of liked gallery posts"
"read:account": "Read account information"
"write:account": "Edit account information"
"read:blocks": "Read which users are blocked"
"write:blocks": "Block and unblock users"
"read:drive": "List files and folders in the drive"
"write:drive": "Create, change and delete files in the drive"
"read:favorites": "List favourited notes"
"write:favorites": "Favorite and unfavorite notes"
"read:following": "List followed and following users"
"write:following": "Follow and unfollow other users"
"read:messaging": "View chat messages and history"
"write:messaging": "Create and delete chat messages"
"read:mutes": "List users which are muted or whose renotes are muted"
"write:mutes": "Mute and unmute users or their renotes"
"write:notes": "Create and delete notes"
"read:notifications": "Read notifications"
"write:notifications": "Mark notifications as read and create custom notifications"
"write:reactions": "Create and delete reactions"
"write:votes": "Vote in polls"
"read:pages": "List and read pages"
"write:pages": "Create, change and delete pages"
"read:page-likes": "List and read page likes"
"write:page-likes": "Like and unlike pages"
"read:user-groups": "List and view joined, owned and invited to groups"
"write:user-groups": "Create, modify, delete, transfer, join and leave groups. Invite and ban others from groups. Accept and reject group invitations."
"read:channels": "List and read followed and joined channels"
"write:channels": "Create, modify, follow and unfollow channels"
"read:gallery": "List and read gallery posts"
"write:gallery": "Create, modify and delete gallery posts"
"read:gallery-likes": "List and read gallery post likes"
"write:gallery-likes": "Like and unlike gallery posts"
_auth:
shareAccess: "Would you like to authorize \"{name}\" to access this account?"
shareAccessAsk: "Are you sure you want to authorize this application to access your\

View File

@ -1,6 +1,6 @@
import Bull from 'bull';
import { In, LessThan } from 'typeorm';
import { AttestationChallenges, Mutings, Signins } from '@/models/index.js';
import { AttestationChallenges, AuthSessions, Mutings, Signins } from '@/models/index.js';
import { publishUserEvent } from '@/services/stream.js';
import { MINUTE, DAY } from '@/const.js';
import { queueLogger } from '@/queue/logger.js';
@ -35,7 +35,11 @@ export async function checkExpired(job: Bull.Job<Record<string, unknown>>, done:
createdAt: LessThan(new Date(new Date().getTime() - 5 * MINUTE)),
});
logger.succ('Deleted expired mutes, signins and attestation challenges.');
await AuthSessions.delete({
createdAt: LessThan(new Date(new Date().getTime() - 15 * MINUTE)),
});
logger.succ('Deleted expired mutes, signins, attestation challenges and unused oauth authorization codes.');
done();
}

View File

@ -0,0 +1,94 @@
import Koa from 'koa';
import { IsNull, Not } from 'typeorm';
import { Apps, AuthSessions, AccessTokens } from '@/models/index.js';
import config from '@/config/index.js';
export async function oauth(ctx: Koa.Context): void {
const {
grant_type,
code,
// TODO: check redirect_uri
// since this is also not checked in the legacy app authentication
// it seems pointless to check it here, and it is also not stored.
redirect_uri,
} = ctx.request.body;
// check if any of the parameters are null or empty string
if ([grant_type, code].some(x => !x)) {
ctx.response.status = 400;
ctx.response.body = {
error: 'invalid_request',
};
return;
}
if (grant_type !== 'authorization_code') {
ctx.response.status = 400;
ctx.response.body = {
error: 'unsupported_grant_type',
error_description: 'only authorization_code grants are supported',
};
return;
}
const authHeader = ctx.headers.authorization;
if (!authHeader?.toLowerCase().startsWith('basic ')) {
ctx.response.status = 401;
ctx.response.set('WWW-Authenticate', 'Basic');
ctx.response.body = {
error: 'invalid_client',
error_description: 'HTTP Basic Authentication required',
};
return;
}
const [client_id, client_secret] = new Buffer(authHeader.slice(6), 'base64')
.toString('ascii')
.split(':', 2);
const [app, session] = await Promise.all([
Apps.findOneBy({
id: client_id,
secret: client_secret,
}),
AuthSessions.findOneBy({
appId: client_id,
token: code,
// only check for approved auth sessions
userId: Not(IsNull()),
}),
]);
if (app == null) {
ctx.response.status = 401;
ctx.response.set('WWW-Authenticate', 'Basic');
ctx.response.body = {
error: 'invalid_client',
error_description: 'authentication failed',
};
return;
}
if (session == null) {
ctx.response.status = 400;
ctx.response.body = {
error: 'invalid_grant',
};
return;
}
const [ token ] = await Promise.all([
AccessTokens.findOneByOrFail({
appId: client_id,
userId: session.userId,
}),
// session is single use
AuthSessions.delete(session.id),
]);
ctx.response.status = 200;
ctx.response.body = {
access_token: token.token,
token_type: 'bearer',
// FIXME: per-token permissions
scope: app.permission.join(' '),
};
};

View File

@ -66,6 +66,7 @@ import * as ep___ap_show from './endpoints/ap/show.js';
import * as ep___app_create from './endpoints/app/create.js';
import * as ep___app_show from './endpoints/app/show.js';
import * as ep___auth_accept from './endpoints/auth/accept.js';
import * as ep___auth_deny from './endpoints/auth/deny.js';
import * as ep___auth_session_generate from './endpoints/auth/session/generate.js';
import * as ep___auth_session_show from './endpoints/auth/session/show.js';
import * as ep___auth_session_userkey from './endpoints/auth/session/userkey.js';
@ -376,6 +377,7 @@ const eps = [
['app/create', ep___app_create],
['app/show', ep___app_show],
['auth/accept', ep___auth_accept],
['auth/deny', ep___auth_deny],
['auth/session/generate', ep___auth_session_generate],
['auth/session/show', ep___auth_session_show],
['auth/session/userkey', ep___auth_session_userkey],

View File

@ -0,0 +1,38 @@
import { AuthSessions } from '@/models/index.js';
import define from '../../define.js';
import { ApiError } from '../../error.js';
export const meta = {
tags: ['auth'],
requireCredential: true,
secure: true,
errors: {
noSuchSession: {
message: 'No such session.',
code: 'NO_SUCH_SESSION',
id: '9c72d8de-391a-43c1-9d06-08d29efde8df',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
token: { type: 'string' },
},
required: ['token'],
} as const;
// eslint-disable-next-line import/no-default-export
export default define(meta, paramDef, async (ps, user) => {
const result = await AuthSessions.delete({
token: ps.token,
});
if (result.affected == 0) {
throw new ApiError(meta.errors.noSuchSession);
}
});

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',
},
},
},
@ -36,17 +49,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,
});
@ -56,10 +79,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,
@ -68,5 +92,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

@ -0,0 +1,5 @@
/*
This route is already in use, but the functionality is provided
by '@/server/api/common/oauth.ts'. The route is not here because
that route requires more deep level access to HTTP data.
*/

View File

@ -15,6 +15,7 @@ import handler from './api-handler.js';
import signup from './private/signup.js';
import signin from './private/signin.js';
import signupPending from './private/signup-pending.js';
import { oauth } from './common/oauth.js';
import discord from './service/discord.js';
import github from './service/github.js';
import twitter from './service/twitter.js';
@ -33,8 +34,9 @@ app.use(async (ctx, next) => {
});
app.use(bodyParser({
// リクエストが multipart/form-data でない限りはJSONだと見なす
detectJSON: ctx => !ctx.is('multipart/form-data'),
// assume it is JSON unless it is multipart/form-data (for file uploads) or
// application/x-www-form-urlencoded (for OAuth)
detectJSON: ctx => !ctx.is('multipart/form-data') && !ctx.is('application/x-www-form-urlencoded'),
}));
// Init multer instance
@ -77,6 +79,9 @@ for (const endpoint of endpoints) {
}
}
// the OAuth endpoint does some shenanigans and can not use the normal API handler
router.post('/auth/session/oauth', oauth);
router.post('/signup', signup);
router.post('/signin', signin);
router.post('/signup-pending', signupPending);

View File

@ -2,6 +2,10 @@ import config from '@/config/index.js';
import endpoints from '../endpoints.js';
import { errors as basicErrors } from './errors.js';
import { schemas, convertSchemaToOpenApiSchema } from './schemas.js';
import { kinds } from '@/misc/api-permissions.js';
import { I18n } from '@/misc/i18n.js';
const i18n = new I18n('en-US');
export function genOpenapiSpec() {
const spec = {
@ -33,10 +37,18 @@ export function genOpenapiSpec() {
in: 'body',
name: 'i',
},
// TODO: change this to oauth2 when the remaining oauth stuff is set up
Bearer: {
type: 'http',
scheme: 'bearer',
OAuth: {
type: 'oauth2',
flows: {
authorizationCode: {
authorizationUrl: `${config.url}/auth`,
tokenUrl: `${config.apiUrl}/auth/session/oauth`,
scopes: kinds.reduce((acc, kind) => {
acc[kind] = i18n.ts['_permissions'][kind];
return acc;
}, {}),
},
},
},
},
},
@ -80,10 +92,16 @@ export function genOpenapiSpec() {
{
ApiKeyAuth: [],
},
{
Bearer: [],
},
];
if (endpoint.meta.kind) {
security.push({
OAuth: [endpoint.meta.kind],
});
} else {
security.push({
OAuth: [],
});
}
if (!endpoint.meta.requireCredential) {
// add this to make authentication optional
security.push({});

View File

@ -8,9 +8,12 @@
</div>
<div class="_content">
<h2>{{ i18n.ts._auth.permissionAsk }}</h2>
<ul>
<ul v-if="app.permission.length > 0">
<li v-for="p in app.permission" :key="p">{{ i18n.t(`_permissions.${p}`) }}</li>
</ul>
<template v-else>
{{ i18n.ts.noPermissionRequested }}
</template>
</div>
<div class="_footer">
<MkButton inline @click="cancel">{{ i18n.ts.cancel }}</MkButton>

View File

@ -1,29 +1,37 @@
<template>
<div v-if="$i">
<MkLoading v-if="state == 'fetching'"/>
<XForm
v-else-if="state == 'waiting'"
ref="form"
class="form"
:session="session"
@denied="state = 'denied'"
@accepted="accepted"
/>
<div v-else-if="state == 'denied'" class="denied">
<h1>{{ i18n.ts._auth.denied }}</h1>
</div>
<div v-else-if="state == 'accepted'" class="accepted">
<h1>{{ session.app.isAuthorized ? i18n.t('already-authorized') : i18n.ts.allowed }}</h1>
<p v-if="session.app.callbackUrl">{{ i18n.ts._auth.callback }}<MkEllipsis/></p>
<p v-if="!session.app.callbackUrl">{{ i18n.ts._auth.pleaseGoBack }}</p>
</div>
<div v-else-if="state == 'fetch-session-error'" class="error">
<p>{{ i18n.ts.somethingHappened }}</p>
</div>
</div>
<div v-else class="signin">
<MkSignin @login="onLogin"/>
</div>
<MkStickyContainer>
<template #header><MkPageHeader/></template>
<MkSpacer :max-content="700">
<div v-if="$i">
<MkLoading v-if="state == 'fetching'"/>
<XForm
v-else-if="state == 'waiting'"
ref="form"
class="form"
:session="session"
@denied="denied"
@accepted="accepted"
/>
<div v-else-if="state == 'denied'" class="denied">
<h1>{{ i18n.ts._auth.denied }}</h1>
</div>
<div v-else-if="state == 'accepted'" class="accepted">
<h1>{{ session.app.isAuthorized ? i18n.t('already-authorized') : i18n.ts.allowed }}</h1>
<p v-if="session.app.callbackUrl">{{ i18n.ts._auth.callback }}<MkEllipsis/></p>
<p v-if="!session.app.callbackUrl">{{ i18n.ts._auth.pleaseGoBack }}</p>
</div>
<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"/>
</div>
</MkSpacer>
</MkStickyContainer>
</template>
<script lang="ts" setup>
@ -33,48 +41,123 @@ import MkSignin from '@/components/signin.vue';
import * as os from '@/os';
import { login , $i } from '@/account';
import { i18n } from '@/i18n';
import { definePageMetadata } from '@/scripts/page-metadata';
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 URLSearchParams
let oauth = null;
onMounted(async () => {
if (!$i) return;
// Fetch session
os.api('auth/session/show', {
token: props.token,
}).then(fetchedSession => {
session = fetchedSession;
//
if (session.app.isAuthorized) {
os.api('auth/accept', {
token: session.token,
}).then(() => {
this.accepted();
});
} else {
state = 'waiting';
// detect whether this is actual OAuth or "legacy" auth
const query = new URLSearchParams(location.search);
if (query.get('response_type') === 'code') {
// OAuth request detected!
oauth = query;
// as a kind of hack, we first have to start the session for the OAuth client
const clientId = query.get('client_id');
if (!clientId) {
state = 'fetch-session-error';
return;
}
}).catch(() => {
session = await os.api('auth/session/generate', {
clientId,
}).catch(e => {
const params = {
error: 'server_error',
};
// try to determine the cause of the error
if (e.code === 'NO_SUCH_APP') {
params.error = 'invalid_request';
params.error_description = 'unknown client_id';
} else if (e.message) {
params.error_description = e.message;
}
if (oauth.has('state')) {
params.state = oauth.get('state');
}
location.href = appendQuery(session.app.callbackUrl, query(params));
});
} 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 while trying to get the session info
if (state === 'fetch-session-error') 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,
};
if (oauth.has('state')) {
params.state = oauth.get('state');
}
location.href = appendQuery(session.app.callbackUrl, 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.',
};
if (oauth.has('state')) {
params.state = oauth.get('state');
}
location.href = appendQuery(session.app.callbackUrl, query(params));
} else {
// legacy auth didn't do anything in this case...
}
}
function onLogin(res): void {
login(res.i);
}
definePageMetadata({
title: i18n.ts.appAuthorization,
icon: 'fas fa-shield',
});
</script>

View File

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