diff --git a/docs/oauth.md b/docs/oauth.md new file mode 100644 index 000000000..fc850c6fe --- /dev/null +++ b/docs/oauth.md @@ -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. diff --git a/locales/en-US.yml b/locales/en-US.yml index a6a8a2110..109e03b94 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -828,6 +828,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" @@ -1078,38 +1082,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\ diff --git a/packages/backend/migration/1667653936442-token-permissions.js b/packages/backend/migration/1667653936442-token-permissions.js new file mode 100644 index 000000000..d6c3e0fba --- /dev/null +++ b/packages/backend/migration/1667653936442-token-permissions.js @@ -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`); + } +} diff --git a/packages/backend/migration/1667738304733-pkce.js b/packages/backend/migration/1667738304733-pkce.js new file mode 100644 index 000000000..4036bd649 --- /dev/null +++ b/packages/backend/migration/1667738304733-pkce.js @@ -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"`); + } +} diff --git a/packages/backend/src/models/entities/auth-session.ts b/packages/backend/src/models/entities/auth-session.ts index 51f79e475..98031f2de 100644 --- a/packages/backend/src/models/entities/auth-session.ts +++ b/packages/backend/src/models/entities/auth-session.ts @@ -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 { User } from './user.js'; +import { AccessToken } from './access-token.js'; import { App } from './app.js'; @Entity() @@ -23,21 +23,27 @@ export class AuthSession { ...id(), nullable: true, }) - public userId: User['id'] | null; + public accessTokenId: AccessToken['id'] | null; - @ManyToOne(type => User, { + @ManyToOne(() => AccessToken, { onDelete: 'CASCADE', nullable: true, }) @JoinColumn() - public user: User | null; + public accessToken: AccessToken | null; @Column(id()) public appId: App['id']; - @ManyToOne(type => App, { + @ManyToOne(() => App, { onDelete: 'CASCADE', }) @JoinColumn() public app: App | null; + + @Column('text', { + nullable: true, + comment: 'PKCE code_challenge value, if provided (OAuth only)', + }) + pkceChallenge: string | null; } diff --git a/packages/backend/src/queue/processors/system/check-expired.ts b/packages/backend/src/queue/processors/system/check-expired.ts index 5608dc43c..fe7012b1d 100644 --- a/packages/backend/src/queue/processors/system/check-expired.ts +++ b/packages/backend/src/queue/processors/system/check-expired.ts @@ -1,6 +1,6 @@ import Bull from 'bull'; 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 { MINUTE, DAY } from '@/const.js'; import { queueLogger } from '@/queue/logger.js'; @@ -40,7 +40,11 @@ export async function checkExpired(job: Bull.Job>, done: 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(); } diff --git a/packages/backend/src/server/api/authenticate.ts b/packages/backend/src/server/api/authenticate.ts index f6d6a646a..25e87b75e 100644 --- a/packages/backend/src/server/api/authenticate.ts +++ b/packages/backend/src/server/api/authenticate.ts @@ -1,16 +1,9 @@ -import { CacheableLocalUser, ILocalUser } from '@/models/entities/user.js'; -import { Users, AccessTokens, Apps } from '@/models/index.js'; +import { CacheableLocalUser } from '@/models/entities/user.js'; +import { Users, AccessTokens } from '@/models/index.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 isNativeToken from './common/is-native-token.js'; -const appCache = new Cache( - Infinity, - (id) => Apps.findOneByOrFail({ id }), -); - export class AuthenticationError extends Error { constructor(message: string) { super(message); @@ -71,15 +64,6 @@ export default async (authorization: string | null | undefined, bodyToken: strin // can't authorize remote users if (!Users.isLocalUser(user)) return [null, null]; - if (accessToken.appId) { - const app = await appCache.fetch(accessToken.appId); - - return [user, { - id: accessToken.id, - permission: app.permission, - } as AccessToken]; - } else { - return [user, accessToken]; - } + return [user, accessToken]; } }; diff --git a/packages/backend/src/server/api/common/compare-url.ts b/packages/backend/src/server/api/common/compare-url.ts new file mode 100644 index 000000000..2d35dbd97 --- /dev/null +++ b/packages/backend/src/server/api/common/compare-url.ts @@ -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; +} diff --git a/packages/backend/src/server/api/common/oauth.ts b/packages/backend/src/server/api/common/oauth.ts new file mode 100644 index 000000000..c09712e9b --- /dev/null +++ b/packages/backend/src/server/api/common/oauth.ts @@ -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(' '), + }; + +}; diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index 3237935d5..c85bb6632 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -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_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'; @@ -375,6 +376,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], diff --git a/packages/backend/src/server/api/endpoints/auth/accept.ts b/packages/backend/src/server/api/endpoints/auth/accept.ts index 691b4a867..736c1e3f6 100644 --- a/packages/backend/src/server/api/endpoints/auth/accept.ts +++ b/packages/backend/src/server/api/endpoints/auth/accept.ts @@ -2,6 +2,7 @@ import * as crypto from 'node:crypto'; import { AuthSessions, AccessTokens, Apps } from '@/models/index.js'; import { genId } from '@/misc/gen-id.js'; import { secureRndstr } from '@/misc/secure-rndstr.js'; +import { kinds } from '@/misc/api-permissions.js'; import define from '../../define.js'; import { ApiError } from '../../error.js'; @@ -19,6 +20,17 @@ export const paramDef = { type: 'object', properties: { 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'], } as const; @@ -34,37 +46,35 @@ export default define(meta, paramDef, async (ps, user) => { // Generate access token const accessToken = secureRndstr(32, true); - // Fetch exist access token - const exist = await AccessTokens.findOneBy({ + // Check for existing access token. + 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, 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 - await AuthSessions.update(session.id, { - userId: user.id, - }); + await AuthSessions.update(session.id, { accessTokenId }); }); diff --git a/packages/backend/src/server/api/endpoints/auth/deny.ts b/packages/backend/src/server/api/endpoints/auth/deny.ts new file mode 100644 index 000000000..ca1a585c7 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/auth/deny.ts @@ -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); + } +}); diff --git a/packages/backend/src/server/api/endpoints/auth/session/generate.ts b/packages/backend/src/server/api/endpoints/auth/session/generate.ts index eeb51abc6..fe1286c03 100644 --- a/packages/backend/src/server/api/endpoints/auth/session/generate.ts +++ b/packages/backend/src/server/api/endpoints/auth/session/generate.ts @@ -2,6 +2,7 @@ import { v4 as uuid } from 'uuid'; import config from '@/config/index.js'; import { Apps, AuthSessions } from '@/models/index.js'; import { genId } from '@/misc/gen-id.js'; +import { compareUrl } from '@/server/api/common/compare-url.js'; import define from '../../../define.js'; import { ApiError } from '../../../error.js'; @@ -23,6 +24,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', + }, }, }, @@ -31,16 +45,33 @@ export const meta = { export const paramDef = { type: 'object', - properties: { - appSecret: { type: 'string' }, - }, - required: ['appSecret'], + oneOf: [{ + properties: { + clientId: { type: 'string' }, + callbackUrl: { + type: 'string', + minLength: 1, + }, + pkceChallenge: { + type: 'string', + minLength: 1, + }, + }, + required: ['clientId'] + }, { + 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, }); @@ -48,19 +79,31 @@ export default define(meta, paramDef, async (ps) => { 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 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, + pkceChallenge: ps.pkceChallenge, }).then(x => AuthSessions.findOneByOrFail(x.identifiers[0])); return { token: doc.token, url: `${config.authUrl}/${doc.token}`, + id, + app: await Apps.pack(app), }; }); diff --git a/packages/backend/src/server/api/endpoints/auth/session/oauth.ts b/packages/backend/src/server/api/endpoints/auth/session/oauth.ts new file mode 100644 index 000000000..d6aa6caab --- /dev/null +++ b/packages/backend/src/server/api/endpoints/auth/session/oauth.ts @@ -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. +*/ diff --git a/packages/backend/src/server/api/endpoints/auth/session/userkey.ts b/packages/backend/src/server/api/endpoints/auth/session/userkey.ts index 3a741db44..3e857645a 100644 --- a/packages/backend/src/server/api/endpoints/auth/session/userkey.ts +++ b/packages/backend/src/server/api/endpoints/auth/session/userkey.ts @@ -46,27 +46,26 @@ export default define(meta, paramDef, async (ps) => { if (app == null) throw new ApiError('NO_SUCH_APP'); // Fetch token - const session = await AuthSessions.findOneBy({ - token: ps.token, - appId: app.id, + const session = await AuthSessions.findOne({ + where: { + token: ps.token, + appId: app.id, + }, + relations: { + accessToken: true, + }, }); if (session == null) throw new ApiError('NO_SUCH_SESSION'); - if (session.userId == null) throw new ApiError('PENDING_SESSION'); - - // Lookup access token - const accessToken = await AccessTokens.findOneByOrFail({ - appId: app.id, - userId: session.userId, - }); + if (session.accessTokenId == null) throw new ApiError('PENDING_SESSION'); // Delete session AuthSessions.delete(session.id); return { - accessToken: accessToken.token, - user: await Users.pack(session.userId, null, { + accessToken: session.accessToken.token, + user: await Users.pack(session.accessToken.userId, null, { detail: true, }), }; diff --git a/packages/backend/src/server/api/index.ts b/packages/backend/src/server/api/index.ts index 140649dcc..456f5ff11 100644 --- a/packages/backend/src/server/api/index.ts +++ b/packages/backend/src/server/api/index.ts @@ -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'; @@ -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('/signin', signin); router.post('/signup-pending', signupPending); diff --git a/packages/backend/src/server/api/openapi/gen-spec.ts b/packages/backend/src/server/api/openapi/gen-spec.ts index f9795884d..a7f5d5cc9 100644 --- a/packages/backend/src/server/api/openapi/gen-spec.ts +++ b/packages/backend/src/server/api/openapi/gen-spec.ts @@ -3,6 +3,10 @@ import { errors as errorDefinitions } from '../error.js'; import endpoints from '../endpoints.js'; import { schemas, convertSchemaToOpenApiSchema } from './schemas.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() { const spec = { @@ -34,10 +38,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; + }, {}), + }, + }, }, }, }, @@ -137,10 +149,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({}); diff --git a/packages/backend/src/server/oauth.ts b/packages/backend/src/server/oauth.ts new file mode 100644 index 000000000..65261ccc9 --- /dev/null +++ b/packages/backend/src/server/oauth.ts @@ -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'], +}); diff --git a/packages/backend/src/server/well-known.ts b/packages/backend/src/server/well-known.ts index f4db66354..527aa99bc 100644 --- a/packages/backend/src/server/well-known.ts +++ b/packages/backend/src/server/well-known.ts @@ -7,6 +7,7 @@ import { escapeAttribute, escapeValue } from '@/prelude/xml.js'; import { Users } from '@/models/index.js'; import { User } from '@/models/entities/user.js'; import { links } from './nodeinfo.js'; +import { oauthMeta } from './oauth.js'; // Init router const router = new Router(); @@ -62,10 +63,21 @@ router.get('/.well-known/nodeinfo', async ctx => { ctx.body = { links }; }); -/* TODO -router.get('/.well-known/change-password', async ctx => { -}); -*/ +function oauth(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 => { const fromId = (id: User['id']): FindOptionsWhere => ({ diff --git a/packages/client/src/pages/auth.form.vue b/packages/client/src/pages/auth.form.vue index d4af3b6db..92226eff1 100644 --- a/packages/client/src/pages/auth.form.vue +++ b/packages/client/src/pages/auth.form.vue @@ -3,14 +3,16 @@
{{ i18n.t('_auth.shareAccess', { name: app.name }) }}

{{ app.name }}

-

{{ app.id }}

{{ app.description }}

{{ i18n.ts._auth.permissionAsk }}

-
    -
  • {{ i18n.t(`_permissions.${p}`) }}
  • +
      +
    • {{ i18n.t(`_permissions.${p}`) }}
    +