server: implement OAuth 2.0 Authorization Code grant

Changelog: Added
Reviewed-on: FoundKeyGang/FoundKey#205
This commit is contained in:
Johann150 2022-12-04 14:06:36 +01:00
commit 946e862ecd
Signed by untrusted user: Johann150
GPG key ID: 9EE6577A2A06F8F1
22 changed files with 686 additions and 167 deletions

42
docs/oauth.md Normal file
View file

@ -0,0 +1,42 @@
# 3rd party access
Foundkey supports:
- OAuth 2.0 Authorization Code grant per RFC 6749.
- OAuth Bearer Token Usage per RFC 6750.
- Proof Key for Code Exchange (PKCE) per RFC 7636.
- OAuth 2.0 Authorization Server Metadata per RFC 8414.
# Discovery
Because the implementation may change in the future, it is recommended that you use OAuth 2.0 Authorization Server Metadata a.k.a. OpenID Connect Discovery.
In short, this means that to discover the URLs for the grant endpoints you should request `/.well-known/oauth-authorization-server`, which is a JSON object.
From there, `authorization_endpoint` and `token_endpoint` will probably be most interesting to you.
The definitions of all data fields are to be found in [RFC 8414, section 2](https://www.rfc-editor.org/rfc/rfc8414#section-2).
# App registration
Before using the OAuth grant you need to register your application.
Currently you will need to use the pre-existing Misskey API to register, though Dynamic Client Registration may be implemented at a later point.
(You'd be able to tell from the Authorization Server Metadata, see above.)
The data you will need to know before registering is the following:
- a name for your app,
- a short description to be shown to users,
- which API permissions you need, and
- the callback URL you want to use.
There can only be 1 callback URL per registration.
Note that you can specify permissions a 2nd time in the OAuth flow.
If you do not provide permissions again in the grant flow, the default is to use all permissions you gave when registering the app.
If you do provide permissions in the grant flow, permissions that were not registered will never be granted.
A list of available permissions can be viewed on any Foundkey instance by going to the API documentation at `/api-doc`.
To register your app you need to `POST` to `/api/app/create`.
The body of the request must be a JSON object with the following keys:
- `name` (string): a name for your app,
- `description` (string): a short description to be shown to users,
- `permission` (array of permission names) which API permissions you need, and
- `callbackUrl` (string): the callback URL you want to use.
If successful (HTTP response code 200) you will receive back a JSON object containing among other things:
- `id` (string): the client ID
- `secret` (string): the client secret
With these credentials you should be able to use the Authorization Code grant to obtain authorization.

View file

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

View file

@ -0,0 +1,26 @@
export class tokenPermissions1667653936442 {
name = 'tokenPermissions1667653936442'
async up(queryRunner) {
// Carry over the permissions from the app for tokens that have an associated app.
await queryRunner.query(`UPDATE "access_token" SET permission = (SELECT permission FROM "app" WHERE "app"."id" = "access_token"."appId") WHERE "appId" IS NOT NULL AND CARDINALITY("permission") = 0`);
// The permission column should now always be set explicitly, so the default is not needed any more.
await queryRunner.query(`ALTER TABLE "access_token" ALTER COLUMN "permission" DROP DEFAULT`);
// Refactor scheme to allow multiple access tokens per app.
await queryRunner.query(`ALTER TABLE "auth_session" DROP CONSTRAINT "FK_c072b729d71697f959bde66ade0"`);
await queryRunner.query(`ALTER TABLE "auth_session" RENAME COLUMN "userId" TO "accessTokenId"`);
await queryRunner.query(`ALTER TABLE "auth_session" ADD CONSTRAINT "UQ_8e001e5a101c6dca37df1a76d66" UNIQUE ("accessTokenId")`);
await queryRunner.query(`ALTER TABLE "auth_session" ADD CONSTRAINT "FK_8e001e5a101c6dca37df1a76d66" FOREIGN KEY ("accessTokenId") REFERENCES "access_token"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "auth_session" DROP CONSTRAINT "FK_8e001e5a101c6dca37df1a76d66"`);
await queryRunner.query(`ALTER TABLE "auth_session" DROP CONSTRAINT "UQ_8e001e5a101c6dca37df1a76d66"`);
await queryRunner.query(`ALTER TABLE "access_token" ALTER COLUMN "permission" DROP DEFAULT`);
await queryRunner.query(`ALTER TABLE "auth_session" RENAME COLUMN "accessTokenId" TO "userId"`);
await queryRunner.query(`ALTER TABLE "auth_session" ADD CONSTRAINT "FK_c072b729d71697f959bde66ade0" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "access_token" ALTER COLUMN "permission" SET DEFAULT '{}'::varchar[]`);
await queryRunner.query(`UPDATE "access_token" SET permission = '{}'::varchar[] WHERE "appId" IS NOT NULL`);
}
}

View file

@ -0,0 +1,12 @@
export class pkce1667738304733 {
name = 'pkce1667738304733'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "auth_session" ADD "pkceChallenge" text`);
await queryRunner.query(`COMMENT ON COLUMN "auth_session"."pkceChallenge" IS 'PKCE code_challenge value, if provided (OAuth only)'`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "auth_session" DROP COLUMN "pkceChallenge"`);
}
}

View file

@ -1,6 +1,6 @@
import { Entity, PrimaryColumn, Index, Column, ManyToOne, JoinColumn } from 'typeorm'; import { Entity, PrimaryColumn, Index, Column, ManyToOne, OneToOne, JoinColumn } from 'typeorm';
import { id } from '../id.js'; import { id } from '../id.js';
import { User } from './user.js'; import { AccessToken } from './access-token.js';
import { App } from './app.js'; import { App } from './app.js';
@Entity() @Entity()
@ -23,21 +23,27 @@ export class AuthSession {
...id(), ...id(),
nullable: true, nullable: true,
}) })
public userId: User['id'] | null; public accessTokenId: AccessToken['id'] | null;
@ManyToOne(type => User, { @ManyToOne(() => AccessToken, {
onDelete: 'CASCADE', onDelete: 'CASCADE',
nullable: true, nullable: true,
}) })
@JoinColumn() @JoinColumn()
public user: User | null; public accessToken: AccessToken | null;
@Column(id()) @Column(id())
public appId: App['id']; public appId: App['id'];
@ManyToOne(type => App, { @ManyToOne(() => App, {
onDelete: 'CASCADE', onDelete: 'CASCADE',
}) })
@JoinColumn() @JoinColumn()
public app: App | null; public app: App | null;
@Column('text', {
nullable: true,
comment: 'PKCE code_challenge value, if provided (OAuth only)',
})
pkceChallenge: string | null;
} }

View file

@ -1,6 +1,6 @@
import Bull from 'bull'; import Bull from 'bull';
import { In, LessThan } from 'typeorm'; import { In, LessThan } from 'typeorm';
import { AttestationChallenges, Mutings, PasswordResetRequests, Signins } from '@/models/index.js'; import { AttestationChallenges, AuthSessions, Mutings, PasswordResetRequests, Signins } from '@/models/index.js';
import { publishUserEvent } from '@/services/stream.js'; import { publishUserEvent } from '@/services/stream.js';
import { MINUTE, DAY } from '@/const.js'; import { MINUTE, DAY } from '@/const.js';
import { queueLogger } from '@/queue/logger.js'; import { queueLogger } from '@/queue/logger.js';
@ -40,7 +40,11 @@ export async function checkExpired(job: Bull.Job<Record<string, unknown>>, done:
createdAt: LessThan(new Date(new Date().getTime() - 30 * MINUTE)), createdAt: LessThan(new Date(new Date().getTime() - 30 * 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 data.');
done(); done();
} }

View file

@ -1,16 +1,9 @@
import { CacheableLocalUser, ILocalUser } from '@/models/entities/user.js'; import { CacheableLocalUser } from '@/models/entities/user.js';
import { Users, AccessTokens, Apps } from '@/models/index.js'; import { Users, AccessTokens } from '@/models/index.js';
import { AccessToken } from '@/models/entities/access-token.js'; import { AccessToken } from '@/models/entities/access-token.js';
import { Cache } from '@/misc/cache.js';
import { App } from '@/models/entities/app.js';
import { userByIdCache, localUserByNativeTokenCache } from '@/services/user-cache.js'; import { userByIdCache, localUserByNativeTokenCache } from '@/services/user-cache.js';
import isNativeToken from './common/is-native-token.js'; import isNativeToken from './common/is-native-token.js';
const appCache = new Cache<App>(
Infinity,
(id) => Apps.findOneByOrFail({ id }),
);
export class AuthenticationError extends Error { export class AuthenticationError extends Error {
constructor(message: string) { constructor(message: string) {
super(message); super(message);
@ -71,15 +64,6 @@ export default async (authorization: string | null | undefined, bodyToken: strin
// can't authorize remote users // can't authorize remote users
if (!Users.isLocalUser(user)) return [null, null]; if (!Users.isLocalUser(user)) return [null, null];
if (accessToken.appId) { return [user, accessToken];
const app = await appCache.fetch(accessToken.appId);
return [user, {
id: accessToken.id,
permission: app.permission,
} as AccessToken];
} else {
return [user, accessToken];
}
} }
}; };

View file

@ -0,0 +1,42 @@
import { URL } from 'node:url';
/**
* Compares two URLs for OAuth. The first parameter is the trusted URL
* which decides how the comparison is conducted.
*
* Invalid URLs are never equal.
*
* Implements the current draft-ietf-oauth-security-topics-21 § 4.1.3
* (published 2022-09-27)
*/
export function compareUrl(trusted: string, untrusted: string): boolean {
let trustedUrl, untrustedUrl;
try {
trustedUrl = new URL(trusted);
untrustedUrl = new URL(untrusted);
} catch {
return false;
}
// Excerpt from RFC 8252:
//> Loopback redirect URIs use the "http" scheme and are constructed with
//> the loopback IP literal and whatever port the client is listening on.
//> That is, "http://127.0.0.1:{port}/{path}" for IPv4, and
//> "http://[::1]:{port}/{path}" for IPv6.
//
// To be nice we also include the "localhost" name, since it is required
// to resolve to one of the other two.
if (trustedUrl.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(trustedUrl.host)) {
// localhost comparisons should ignore port number
trustedUrl.port = '';
untrustedUrl.port = '';
}
// security recommendation is to just compare the (normalized) string
//> This document therefore advises to simplify the required logic and configuration
//> by using exact redirect URI matching. This means the authorization server MUST
//> compare the two URIs using simple string comparison as defined in [RFC3986],
//> Section 6.2.1.
return trustedUrl.href === untrustedUrl.href;
}

View file

@ -0,0 +1,131 @@
import * as crypto from 'node:crypto';
import Koa from 'koa';
import { IsNull, Not } from 'typeorm';
import { Apps, AuthSessions, AccessTokens } from '@/models/index.js';
import config from '@/config/index.js';
import { compareUrl } from './compare-url.js';
export async function oauth(ctx: Koa.Context): void {
const {
grant_type,
code,
redirect_uri,
code_verifier,
} = 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.findOne({
where: {
appId: client_id,
token: code,
// only check for approved auth sessions
accessTokenId: Not(IsNull()),
},
relations: {
accessToken: true,
},
}),
]);
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;
}
// check PKCE challenge, if provided before
if (session.pkceChallenge) {
// Also checking the client's homework, the RFC says:
//> minimum length of 43 characters and a maximum length of 128 characters
if (!code_verifier || code_verifier.length < 43 || code_verifier.length > 128) {
ctx.response.status = 400;
ctx.response.body = {
error: 'invalid_grant',
error_description: 'invalid or missing PKCE code_verifier',
};
return;
} else {
// verify that (from RFC 7636):
//> BASE64URL-ENCODE(SHA256(ASCII(code_verifier))) == code_challenge
const hash = crypto.createHash('sha256');
hash.update(code_verifier);
if (hash.digest('base64url') !== code_challenge) {
ctx.response.status = 400;
ctx.response.body = {
error: 'invalid_grant',
error_description: 'invalid PKCE code_verifier',
};
return;
}
}
}
// check redirect URI
if (!compareUrl(app.callbackUrl, redirect_uri)) {
ctx.response.status = 400;
ctx.response.body = {
error: 'invalid_grant',
error_description: 'Mismatched redirect_uri',
};
return;
}
// session is single use
await AuthSessions.delete(session.id),
ctx.response.status = 200;
ctx.response.body = {
access_token: session.accessToken.token,
token_type: 'bearer',
scope: session.accessToken.permission.join(' '),
};
};

View file

@ -67,6 +67,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_create from './endpoints/app/create.js';
import * as ep___app_show from './endpoints/app/show.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_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_generate from './endpoints/auth/session/generate.js';
import * as ep___auth_session_show from './endpoints/auth/session/show.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'; import * as ep___auth_session_userkey from './endpoints/auth/session/userkey.js';
@ -375,6 +376,7 @@ const eps = [
['app/create', ep___app_create], ['app/create', ep___app_create],
['app/show', ep___app_show], ['app/show', ep___app_show],
['auth/accept', ep___auth_accept], ['auth/accept', ep___auth_accept],
['auth/deny', ep___auth_deny],
['auth/session/generate', ep___auth_session_generate], ['auth/session/generate', ep___auth_session_generate],
['auth/session/show', ep___auth_session_show], ['auth/session/show', ep___auth_session_show],
['auth/session/userkey', ep___auth_session_userkey], ['auth/session/userkey', ep___auth_session_userkey],

View file

@ -2,6 +2,7 @@ import * as crypto from 'node:crypto';
import { AuthSessions, AccessTokens, Apps } from '@/models/index.js'; import { AuthSessions, AccessTokens, Apps } from '@/models/index.js';
import { genId } from '@/misc/gen-id.js'; import { genId } from '@/misc/gen-id.js';
import { secureRndstr } from '@/misc/secure-rndstr.js'; import { secureRndstr } from '@/misc/secure-rndstr.js';
import { kinds } from '@/misc/api-permissions.js';
import define from '../../define.js'; import define from '../../define.js';
import { ApiError } from '../../error.js'; import { ApiError } from '../../error.js';
@ -19,6 +20,17 @@ export const paramDef = {
type: 'object', type: 'object',
properties: { properties: {
token: { type: 'string' }, token: { type: 'string' },
permission: {
description: 'The permissions which the user wishes to grant in this token. '
+ 'Permissions that the app has not registered before will be removed. '
+ 'Defaults to all permissions the app was registered with if not provided.',
type: 'array',
uniqueItems: true,
items: {
type: 'string',
enum: kinds,
},
},
}, },
required: ['token'], required: ['token'],
} as const; } as const;
@ -34,37 +46,35 @@ export default define(meta, paramDef, async (ps, user) => {
// Generate access token // Generate access token
const accessToken = secureRndstr(32, true); const accessToken = secureRndstr(32, true);
// Fetch exist access token // Check for existing access token.
const exist = await AccessTokens.findOneBy({ const app = await Apps.findOneByOrFail({ id: session.appId });
// Generate Hash
const sha256 = crypto.createHash('sha256');
sha256.update(accessToken + app.secret);
const hash = sha256.digest('hex');
const now = new Date();
// Calculate the set intersection between requested permissions and
// permissions that the app registered with. If no specific permissions
// are given, grant all permissions the app registered with.
const permission = ps.permission?.filter(x => app.permission.includes(x)) ?? app.permission;
const accessTokenId = genId();
// Insert access token doc
await AccessTokens.insert({
id: accessTokenId,
createdAt: now,
lastUsedAt: now,
appId: session.appId, appId: session.appId,
userId: user.id, userId: user.id,
token: accessToken,
hash,
permission,
}); });
if (exist == null) {
// Lookup app
const app = await Apps.findOneByOrFail({ id: session.appId });
// Generate Hash
const sha256 = crypto.createHash('sha256');
sha256.update(accessToken + app.secret);
const hash = sha256.digest('hex');
const now = new Date();
// Insert access token doc
await AccessTokens.insert({
id: genId(),
createdAt: now,
lastUsedAt: now,
appId: session.appId,
userId: user.id,
token: accessToken,
hash,
});
}
// Update session // Update session
await AuthSessions.update(session.id, { await AuthSessions.update(session.id, { accessTokenId });
userId: user.id,
});
}); });

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

@ -2,6 +2,7 @@ import { v4 as uuid } from 'uuid';
import config from '@/config/index.js'; import config from '@/config/index.js';
import { Apps, AuthSessions } from '@/models/index.js'; import { Apps, AuthSessions } from '@/models/index.js';
import { genId } from '@/misc/gen-id.js'; import { genId } from '@/misc/gen-id.js';
import { compareUrl } from '@/server/api/common/compare-url.js';
import define from '../../../define.js'; import define from '../../../define.js';
import { ApiError } from '../../../error.js'; import { ApiError } from '../../../error.js';
@ -23,6 +24,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',
},
}, },
}, },
@ -31,16 +45,33 @@ export const meta = {
export const paramDef = { export const paramDef = {
type: 'object', type: 'object',
properties: { oneOf: [{
appSecret: { type: 'string' }, properties: {
}, clientId: { type: 'string' },
required: ['appSecret'], callbackUrl: {
type: 'string',
minLength: 1,
},
pkceChallenge: {
type: 'string',
minLength: 1,
},
},
required: ['clientId']
}, {
properties: {
appSecret: { type: 'string' },
},
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,
}); });
@ -48,19 +79,31 @@ export default define(meta, paramDef, async (ps) => {
throw new ApiError('NO_SUCH_APP'); throw new ApiError('NO_SUCH_APP');
} }
// check URL if provided
// technically the OAuth specification says that the redirect URI has to be
// bound with the token request, but since an app may only register one
// redirect URI, we don't actually have to store that.
if (ps.callbackUrl && !compareUrl(app.callbackUrl, ps.callbackUrl)) {
throw new ApiError('NO_SUCH_APP', 'redirect URI mismatch');
}
// 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,
pkceChallenge: ps.pkceChallenge,
}).then(x => AuthSessions.findOneByOrFail(x.identifiers[0])); }).then(x => AuthSessions.findOneByOrFail(x.identifiers[0]));
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

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

@ -46,27 +46,26 @@ export default define(meta, paramDef, async (ps) => {
if (app == null) throw new ApiError('NO_SUCH_APP'); if (app == null) throw new ApiError('NO_SUCH_APP');
// Fetch token // Fetch token
const session = await AuthSessions.findOneBy({ const session = await AuthSessions.findOne({
token: ps.token, where: {
appId: app.id, token: ps.token,
appId: app.id,
},
relations: {
accessToken: true,
},
}); });
if (session == null) throw new ApiError('NO_SUCH_SESSION'); if (session == null) throw new ApiError('NO_SUCH_SESSION');
if (session.userId == null) throw new ApiError('PENDING_SESSION'); if (session.accessTokenId == null) throw new ApiError('PENDING_SESSION');
// Lookup access token
const accessToken = await AccessTokens.findOneByOrFail({
appId: app.id,
userId: session.userId,
});
// Delete session // Delete session
AuthSessions.delete(session.id); AuthSessions.delete(session.id);
return { return {
accessToken: accessToken.token, accessToken: session.accessToken.token,
user: await Users.pack(session.userId, null, { user: await Users.pack(session.accessToken.userId, null, {
detail: true, detail: true,
}), }),
}; };

View file

@ -15,6 +15,7 @@ import { handler } from './api-handler.js';
import signup from './private/signup.js'; import signup from './private/signup.js';
import signin from './private/signin.js'; import signin from './private/signin.js';
import signupPending from './private/signup-pending.js'; import signupPending from './private/signup-pending.js';
import { oauth } from './common/oauth.js';
import discord from './service/discord.js'; import discord from './service/discord.js';
import github from './service/github.js'; import github from './service/github.js';
import twitter from './service/twitter.js'; import twitter from './service/twitter.js';
@ -74,6 +75,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('/signup', signup);
router.post('/signin', signin); router.post('/signin', signin);
router.post('/signup-pending', signupPending); router.post('/signup-pending', signupPending);

View file

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

View file

@ -0,0 +1,16 @@
import { kinds } from '@/misc/api-permissions.js';
import config from '@/config/index.js';
// Since it cannot change while the server is running, we can serialize it once
// instead of having to serialize it every time it is requested.
export const oauthMeta = JSON.stringify({
issuer: config.url,
authorization_endpoint: `${config.url}/auth`,
token_endpoint: `${config.apiUrl}/auth/session/oauth`,
scopes_supported: kinds,
response_types_supported: ['code'],
grant_types_supported: ['authorization_code'],
token_endpoint_auth_methods_supported: ['client_secret_basic'],
service_documentation: `${config.url}/api-doc`,
code_challenge_methods_supported: ['S256'],
});

View file

@ -7,6 +7,7 @@ import { escapeAttribute, escapeValue } from '@/prelude/xml.js';
import { Users } from '@/models/index.js'; import { Users } from '@/models/index.js';
import { User } from '@/models/entities/user.js'; import { User } from '@/models/entities/user.js';
import { links } from './nodeinfo.js'; import { links } from './nodeinfo.js';
import { oauthMeta } from './oauth.js';
// Init router // Init router
const router = new Router(); const router = new Router();
@ -62,10 +63,21 @@ router.get('/.well-known/nodeinfo', async ctx => {
ctx.body = { links }; ctx.body = { links };
}); });
/* TODO function oauth(ctx) {
router.get('/.well-known/change-password', async ctx => { ctx.body = oauthMeta;
}); ctx.type = 'application/json';
*/ ctx.set('Cache-Control', 'max-age=31536000, immutable');
}
// implements RFC 8414
router.get('/.well-known/oauth-authorization-server', oauth);
// From the above RFC:
//> The identifiers "/.well-known/openid-configuration" [...] contain strings
//> referring to the OpenID Connect family of specifications [...]. Despite the reuse
//> of these identifiers that appear to be OpenID specific, their usage in this
//> specification is actually referring to general OAuth 2.0 features that are not
//> specific to OpenID Connect.
router.get('/.well-known/openid-configuration', oauth);
router.get(webFingerPath, async ctx => { router.get(webFingerPath, async ctx => {
const fromId = (id: User['id']): FindOptionsWhere<User> => ({ const fromId = (id: User['id']): FindOptionsWhere<User> => ({

View file

@ -3,14 +3,16 @@
<div class="_title">{{ i18n.t('_auth.shareAccess', { name: app.name }) }}</div> <div class="_title">{{ i18n.t('_auth.shareAccess', { name: app.name }) }}</div>
<div class="_content"> <div class="_content">
<h2>{{ app.name }}</h2> <h2>{{ app.name }}</h2>
<p class="id">{{ app.id }}</p>
<p class="description">{{ app.description }}</p> <p class="description">{{ app.description }}</p>
</div> </div>
<div class="_content"> <div class="_content">
<h2>{{ i18n.ts._auth.permissionAsk }}</h2> <h2>{{ i18n.ts._auth.permissionAsk }}</h2>
<ul> <ul v-if="permission.length > 0">
<li v-for="p in app.permission" :key="p">{{ i18n.t(`_permissions.${p}`) }}</li> <li v-for="p in permission" :key="p">{{ i18n.t(`_permissions.${p}`) }}</li>
</ul> </ul>
<template v-else>
{{ i18n.ts.noPermissionRequested }}
</template>
</div> </div>
<div class="_footer"> <div class="_footer">
<MkButton inline @click="cancel">{{ i18n.ts.cancel }}</MkButton> <MkButton inline @click="cancel">{{ i18n.ts.cancel }}</MkButton>
@ -30,12 +32,12 @@ const emit = defineEmits<{
}>(); }>();
const props = defineProps<{ const props = defineProps<{
// TODO: allow user to deselect some permissions
permission: string[];
session: { session: {
app: { app: {
name: string; name: string;
id: string;
description: string; description: string;
permission: string[];
}; };
token: string; token: string;
}; };
@ -54,6 +56,7 @@ function cancel(): void {
function accept(): void { function accept(): void {
os.api('auth/accept', { os.api('auth/accept', {
token: props.session.token, token: props.session.token,
permission: props.permission,
}).then(() => { }).then(() => {
emit('accepted'); emit('accepted');
}); });

View file

@ -1,29 +1,38 @@
<template> <template>
<div v-if="$i"> <MkStickyContainer>
<MkLoading v-if="state == 'fetching'"/> <template #header><MkPageHeader/></template>
<XForm <MkSpacer :max-content="700">
v-else-if="state == 'waiting'" <div v-if="$i">
ref="form" <MkLoading v-if="state == 'fetching'"/>
class="form" <XForm
:session="session" v-else-if="state == 'waiting'"
@denied="state = 'denied'" ref="form"
@accepted="accepted" class="form"
/> :session="session"
<div v-else-if="state == 'denied'" class="denied"> :permission="permission"
<h1>{{ i18n.ts._auth.denied }}</h1> @denied="denied"
</div> @accepted="accepted"
<div v-else-if="state == 'accepted'" class="accepted"> />
<h1>{{ session.app.isAuthorized ? i18n.t('already-authorized') : i18n.ts.allowed }}</h1> <div v-else-if="state == 'denied'" class="denied">
<p v-if="session.app.callbackUrl">{{ i18n.ts._auth.callback }}<MkEllipsis/></p> <h1>{{ i18n.ts._auth.denied }}</h1>
<p v-if="!session.app.callbackUrl">{{ i18n.ts._auth.pleaseGoBack }}</p> </div>
</div> <div v-else-if="state == 'accepted'" class="accepted">
<div v-else-if="state == 'fetch-session-error'" class="error"> <h1>{{ session.app.isAuthorized ? i18n.t('already-authorized') : i18n.ts.allowed }}</h1>
<p>{{ i18n.ts.somethingHappened }}</p> <p v-if="session.app.callbackUrl">{{ i18n.ts._auth.callback }}<MkEllipsis/></p>
</div> <p v-if="!session.app.callbackUrl">{{ i18n.ts._auth.pleaseGoBack }}</p>
</div> </div>
<div v-else class="signin"> <div v-else-if="state == 'fetch-session-error'" class="error">
<MkSignin @login="onLogin"/> <p>{{ i18n.ts.somethingHappened }}</p>
</div> </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> </template>
<script lang="ts" setup> <script lang="ts" setup>
@ -33,48 +42,155 @@ import MkSignin from '@/components/signin.vue';
import * as os from '@/os'; import * as os from '@/os';
import { login , $i } from '@/account'; import { login , $i } from '@/account';
import { i18n } from '@/i18n'; import { i18n } from '@/i18n';
import { definePageMetadata } from '@/scripts/page-metadata';
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);
let permission: string[] = $ref([]);
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;
// // if PKCE is used, check that it is a supported method
if (session.app.isAuthorized) { // the default value for code_challenge_method if not supplied is 'plain', which is not supported.
os.api('auth/accept', { if (params.has('code_challenge') && params.get('code_challenge_method') !== 'S256') {
token: session.token, if (params.has('redirect_uri')) {
}).then(() => { location.href = appendQuery(params.get('redirect_uri'), query({
this.accepted(); error: 'invalid_request',
}); error_description: 'unsupported code_challenge_method, only "S256" is supported',
} else { }));
state = 'waiting'; } else {
state = 'oauth-error';
}
return;
} }
}).catch(() => {
// 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;
}
session = await os.api('auth/session/generate', {
clientId,
// make the server check the redirect, if provided
callbackUrl: params.get('redirect_uri') ?? undefined,
pkceChallenge: params.get('code_challenge') ?? undefined,
}).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,
};
if (params.has('scope')) {
// If there are specific permissions requested, they have to be a subset of the apps permissions.
permission = params.get('scope')
.split(' ')
.filter(scope => session.app.permission.includes(scope));
} else {
// Default to all permissions of this app.
permission = session.app.permission;
}
} else if (!props.token) {
state = 'fetch-session-error'; state = 'fetch-session-error';
}); } else {
session = await os.api('auth/session/show', {
token: props.token,
}).catch(() => {
state = 'fetch-session-error';
});
permission = session?.app.permission ?? [];
}
// 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,
permission,
}).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);
} }
definePageMetadata({
title: i18n.ts.appAuthorization,
icon: 'fas fa-shield',
});
</script> </script>

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