forked from FoundKeyGang/FoundKey
server: implement OAuth 2.0 Authorization Code grant
Changelog: Added Reviewed-on: FoundKeyGang/FoundKey#205
This commit is contained in:
commit
946e862ecd
22 changed files with 686 additions and 167 deletions
42
docs/oauth.md
Normal file
42
docs/oauth.md
Normal 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.
|
|
@ -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\
|
||||
|
|
|
@ -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`);
|
||||
}
|
||||
}
|
12
packages/backend/migration/1667738304733-pkce.js
Normal file
12
packages/backend/migration/1667738304733-pkce.js
Normal 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"`);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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<Record<string, unknown>>, 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();
|
||||
}
|
||||
|
|
|
@ -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<App>(
|
||||
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];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
42
packages/backend/src/server/api/common/compare-url.ts
Normal file
42
packages/backend/src/server/api/common/compare-url.ts
Normal 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;
|
||||
}
|
131
packages/backend/src/server/api/common/oauth.ts
Normal file
131
packages/backend/src/server/api/common/oauth.ts
Normal 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(' '),
|
||||
};
|
||||
|
||||
};
|
|
@ -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],
|
||||
|
|
|
@ -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,14 +46,7 @@ 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({
|
||||
appId: session.appId,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
if (exist == null) {
|
||||
// Lookup app
|
||||
// Check for existing access token.
|
||||
const app = await Apps.findOneByOrFail({ id: session.appId });
|
||||
|
||||
// Generate Hash
|
||||
|
@ -51,20 +56,25 @@ export default define(meta, paramDef, async (ps, user) => {
|
|||
|
||||
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: genId(),
|
||||
id: accessTokenId,
|
||||
createdAt: now,
|
||||
lastUsedAt: now,
|
||||
appId: session.appId,
|
||||
userId: user.id,
|
||||
token: accessToken,
|
||||
hash,
|
||||
permission,
|
||||
});
|
||||
}
|
||||
|
||||
// Update session
|
||||
await AuthSessions.update(session.id, {
|
||||
userId: user.id,
|
||||
});
|
||||
await AuthSessions.update(session.id, { accessTokenId });
|
||||
});
|
||||
|
|
38
packages/backend/src/server/api/endpoints/auth/deny.ts
Normal file
38
packages/backend/src/server/api/endpoints/auth/deny.ts
Normal 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);
|
||||
}
|
||||
});
|
|
@ -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',
|
||||
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),
|
||||
};
|
||||
});
|
||||
|
|
|
@ -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.
|
||||
*/
|
|
@ -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({
|
||||
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,
|
||||
}),
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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({});
|
||||
|
|
16
packages/backend/src/server/oauth.ts
Normal file
16
packages/backend/src/server/oauth.ts
Normal 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'],
|
||||
});
|
|
@ -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<User> => ({
|
||||
|
|
|
@ -3,14 +3,16 @@
|
|||
<div class="_title">{{ i18n.t('_auth.shareAccess', { name: app.name }) }}</div>
|
||||
<div class="_content">
|
||||
<h2>{{ app.name }}</h2>
|
||||
<p class="id">{{ app.id }}</p>
|
||||
<p class="description">{{ app.description }}</p>
|
||||
</div>
|
||||
<div class="_content">
|
||||
<h2>{{ i18n.ts._auth.permissionAsk }}</h2>
|
||||
<ul>
|
||||
<li v-for="p in app.permission" :key="p">{{ i18n.t(`_permissions.${p}`) }}</li>
|
||||
<ul v-if="permission.length > 0">
|
||||
<li v-for="p in 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>
|
||||
|
@ -30,12 +32,12 @@ const emit = defineEmits<{
|
|||
}>();
|
||||
|
||||
const props = defineProps<{
|
||||
// TODO: allow user to deselect some permissions
|
||||
permission: string[];
|
||||
session: {
|
||||
app: {
|
||||
name: string;
|
||||
id: string;
|
||||
description: string;
|
||||
permission: string[];
|
||||
};
|
||||
token: string;
|
||||
};
|
||||
|
@ -54,6 +56,7 @@ function cancel(): void {
|
|||
function accept(): void {
|
||||
os.api('auth/accept', {
|
||||
token: props.session.token,
|
||||
permission: props.permission,
|
||||
}).then(() => {
|
||||
emit('accepted');
|
||||
});
|
||||
|
|
|
@ -1,4 +1,7 @@
|
|||
<template>
|
||||
<MkStickyContainer>
|
||||
<template #header><MkPageHeader/></template>
|
||||
<MkSpacer :max-content="700">
|
||||
<div v-if="$i">
|
||||
<MkLoading v-if="state == 'fetching'"/>
|
||||
<XForm
|
||||
|
@ -6,7 +9,8 @@
|
|||
ref="form"
|
||||
class="form"
|
||||
:session="session"
|
||||
@denied="state = 'denied'"
|
||||
:permission="permission"
|
||||
@denied="denied"
|
||||
@accepted="accepted"
|
||||
/>
|
||||
<div v-else-if="state == 'denied'" class="denied">
|
||||
|
@ -20,10 +24,15 @@
|
|||
<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 +42,155 @@ 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);
|
||||
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;
|
||||
|
||||
// Fetch session
|
||||
os.api('auth/session/show', {
|
||||
token: props.token,
|
||||
}).then(fetchedSession => {
|
||||
session = fetchedSession;
|
||||
// detect whether this is actual OAuth or "legacy" auth
|
||||
const params = new URLSearchParams(location.search);
|
||||
if (params.get('response_type') === 'code') {
|
||||
// OAuth request detected!
|
||||
|
||||
// 既に連携していた場合
|
||||
if (session.app.isAuthorized) {
|
||||
os.api('auth/accept', {
|
||||
token: session.token,
|
||||
}).then(() => {
|
||||
this.accepted();
|
||||
});
|
||||
// if PKCE is used, check that it is a supported method
|
||||
// the default value for code_challenge_method if not supplied is 'plain', which is not supported.
|
||||
if (params.has('code_challenge') && params.get('code_challenge_method') !== 'S256') {
|
||||
if (params.has('redirect_uri')) {
|
||||
location.href = appendQuery(params.get('redirect_uri'), query({
|
||||
error: 'invalid_request',
|
||||
error_description: 'unsupported code_challenge_method, only "S256" is supported',
|
||||
}));
|
||||
} else {
|
||||
state = 'waiting';
|
||||
state = 'oauth-error';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 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';
|
||||
} 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 {
|
||||
state = 'accepted';
|
||||
if (session.app.callbackUrl) {
|
||||
if (oauth) {
|
||||
// redirect with authorization token
|
||||
const params = {
|
||||
code: session.token,
|
||||
...(oauth.state ? { state: oauth.state } : {}),
|
||||
};
|
||||
|
||||
location.href = appendQuery(oauth.callback, query(params));
|
||||
} else if (session.app.callbackUrl) {
|
||||
// do whatever the legacy auth did
|
||||
location.href = appendQuery(session.app.callbackUrl, query({ token: session.token }));
|
||||
}
|
||||
}
|
||||
|
||||
function denied(): void {
|
||||
state = 'denied';
|
||||
if (oauth) {
|
||||
// redirect with error code
|
||||
const params = {
|
||||
error: 'access_denied',
|
||||
error_description: 'The user denied permission.',
|
||||
...(oauth.state ? { state: oauth.state } : {}),
|
||||
};
|
||||
|
||||
location.href = appendQuery(oauth.callback, query(params));
|
||||
} else {
|
||||
// legacy auth didn't do anything in this case...
|
||||
}
|
||||
}
|
||||
|
||||
function onLogin(res): void {
|
||||
login(res.i);
|
||||
}
|
||||
|
||||
definePageMetadata({
|
||||
title: i18n.ts.appAuthorization,
|
||||
icon: 'fas fa-shield',
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -94,6 +94,9 @@ export const routes = [{
|
|||
}, {
|
||||
path: '/preview',
|
||||
component: page(() => import('./pages/preview.vue')),
|
||||
}, {
|
||||
path: '/auth',
|
||||
component: page(() => import('./pages/auth.vue')),
|
||||
}, {
|
||||
path: '/auth/:token',
|
||||
component: page(() => import('./pages/auth.vue')),
|
||||
|
|
Loading…
Reference in a new issue