summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorKagami Sascha Rosylight <saschanaz@outlook.com>2023-06-28 06:37:13 +0200
committerGitHub <noreply@github.com>2023-06-28 13:37:13 +0900
commit1b1f82a2e26ddabd8bdf400760a817acbf290157 (patch)
treee4da4f3250988017760edb806858b8a77d33f1c9
parentrefactor(backend/test): add `interface UserToken` (#11050) (diff)
downloadmisskey-1b1f82a2e26ddabd8bdf400760a817acbf290157.tar.gz
misskey-1b1f82a2e26ddabd8bdf400760a817acbf290157.tar.bz2
misskey-1b1f82a2e26ddabd8bdf400760a817acbf290157.zip
feat(backend): accept OAuth bearer token (#11052)
* feat(backend): accept OAuth bearer token * refactor * Update packages/backend/src/server/api/ApiCallService.ts Co-authored-by: Acid Chicken (硫酸鶏) <root@acid-chicken.com> * Update packages/backend/src/server/api/ApiCallService.ts Co-authored-by: Acid Chicken (硫酸鶏) <root@acid-chicken.com> * fix * kind: permission for account moved error * also for suspended error * Update packages/backend/src/server/api/StreamingApiServerService.ts Co-authored-by: Acid Chicken (硫酸鶏) <root@acid-chicken.com> --------- Co-authored-by: Acid Chicken (硫酸鶏) <root@acid-chicken.com> Co-authored-by: syuilo <Syuilotan@yahoo.co.jp>
-rw-r--r--packages/backend/src/server/api/ApiCallService.ts81
-rw-r--r--packages/backend/src/server/api/StreamingApiServerService.ts14
-rw-r--r--packages/backend/test/e2e/api.ts99
-rw-r--r--packages/backend/test/utils.ts57
-rw-r--r--packages/misskey-js/etc/misskey-js.api.md12
-rw-r--r--packages/misskey-js/src/api.types.ts11
6 files changed, 222 insertions, 52 deletions
diff --git a/packages/backend/src/server/api/ApiCallService.ts b/packages/backend/src/server/api/ApiCallService.ts
index 45fb473763..09e3724394 100644
--- a/packages/backend/src/server/api/ApiCallService.ts
+++ b/packages/backend/src/server/api/ApiCallService.ts
@@ -53,44 +53,72 @@ export class ApiCallService implements OnApplicationShutdown {
}, 1000 * 60 * 60);
}
+ #sendApiError(reply: FastifyReply, err: ApiError): void {
+ let statusCode = err.httpStatusCode;
+ if (err.httpStatusCode === 401) {
+ reply.header('WWW-Authenticate', 'Bearer realm="Misskey"');
+ } else if (err.kind === 'client') {
+ reply.header('WWW-Authenticate', `Bearer realm="Misskey", error="invalid_request", error_description="${err.message}"`);
+ statusCode = statusCode ?? 400;
+ } else if (err.kind === 'permission') {
+ // (ROLE_PERMISSION_DENIEDは関係ない)
+ if (err.code === 'PERMISSION_DENIED') {
+ reply.header('WWW-Authenticate', `Bearer realm="Misskey", error="insufficient_scope", error_description="${err.message}"`);
+ }
+ statusCode = statusCode ?? 403;
+ } else if (!statusCode) {
+ statusCode = 500;
+ }
+ this.send(reply, statusCode, err);
+ }
+
+ #sendAuthenticationError(reply: FastifyReply, err: unknown): void {
+ if (err instanceof AuthenticationError) {
+ const message = 'Authentication failed. Please ensure your token is correct.';
+ reply.header('WWW-Authenticate', `Bearer realm="Misskey", error="invalid_token", error_description="${message}"`);
+ this.send(reply, 401, new ApiError({
+ message: 'Authentication failed. Please ensure your token is correct.',
+ code: 'AUTHENTICATION_FAILED',
+ id: 'b0a7f5f8-dc2f-4171-b91f-de88ad238e14',
+ }));
+ } else {
+ this.send(reply, 500, new ApiError());
+ }
+ }
+
@bindThis
public handleRequest(
endpoint: IEndpoint & { exec: any },
request: FastifyRequest<{ Body: Record<string, unknown> | undefined, Querystring: Record<string, unknown> }>,
reply: FastifyReply,
- ) {
+ ): void {
const body = request.method === 'GET'
? request.query
: request.body;
- const token = body?.['i'];
+ // https://datatracker.ietf.org/doc/html/rfc6750.html#section-2.1 (case sensitive)
+ const token = request.headers.authorization?.startsWith('Bearer ')
+ ? request.headers.authorization.slice(7)
+ : body?.['i'];
if (token != null && typeof token !== 'string') {
reply.code(400);
return;
}
this.authenticateService.authenticate(token).then(([user, app]) => {
this.call(endpoint, user, app, body, null, request).then((res) => {
- if (request.method === 'GET' && endpoint.meta.cacheSec && !body?.['i'] && !user) {
+ if (request.method === 'GET' && endpoint.meta.cacheSec && !token && !user) {
reply.header('Cache-Control', `public, max-age=${endpoint.meta.cacheSec}`);
}
this.send(reply, res);
}).catch((err: ApiError) => {
- this.send(reply, err.httpStatusCode ? err.httpStatusCode : err.kind === 'client' ? 400 : err.kind === 'permission' ? 403 : 500, err);
+ this.#sendApiError(reply, err);
});
if (user) {
this.logIp(request, user);
}
}).catch(err => {
- if (err instanceof AuthenticationError) {
- this.send(reply, 401, new ApiError({
- message: 'Authentication failed. Please ensure your token is correct.',
- code: 'AUTHENTICATION_FAILED',
- id: 'b0a7f5f8-dc2f-4171-b91f-de88ad238e14',
- }));
- } else {
- this.send(reply, 500, new ApiError());
- }
+ this.#sendAuthenticationError(reply, err);
});
}
@@ -99,7 +127,7 @@ export class ApiCallService implements OnApplicationShutdown {
endpoint: IEndpoint & { exec: any },
request: FastifyRequest<{ Body: Record<string, unknown>, Querystring: Record<string, unknown> }>,
reply: FastifyReply,
- ) {
+ ): Promise<void> {
const multipartData = await request.file().catch(() => {
/* Fastify throws if the remote didn't send multipart data. Return 400 below. */
});
@@ -117,7 +145,10 @@ export class ApiCallService implements OnApplicationShutdown {
fields[k] = typeof v === 'object' && 'value' in v ? v.value : undefined;
}
- const token = fields['i'];
+ // https://datatracker.ietf.org/doc/html/rfc6750.html#section-2.1 (case sensitive)
+ const token = request.headers.authorization?.startsWith('Bearer ')
+ ? request.headers.authorization.slice(7)
+ : fields['i'];
if (token != null && typeof token !== 'string') {
reply.code(400);
return;
@@ -129,22 +160,14 @@ export class ApiCallService implements OnApplicationShutdown {
}, request).then((res) => {
this.send(reply, res);
}).catch((err: ApiError) => {
- this.send(reply, err.httpStatusCode ? err.httpStatusCode : err.kind === 'client' ? 400 : err.kind === 'permission' ? 403 : 500, err);
+ this.#sendApiError(reply, err);
});
if (user) {
this.logIp(request, user);
}
}).catch(err => {
- if (err instanceof AuthenticationError) {
- this.send(reply, 401, new ApiError({
- message: 'Authentication failed. Please ensure your token is correct.',
- code: 'AUTHENTICATION_FAILED',
- id: 'b0a7f5f8-dc2f-4171-b91f-de88ad238e14',
- }));
- } else {
- this.send(reply, 500, new ApiError());
- }
+ this.#sendAuthenticationError(reply, err);
});
}
@@ -213,7 +236,7 @@ export class ApiCallService implements OnApplicationShutdown {
}
if (ep.meta.limit) {
- // koa will automatically load the `X-Forwarded-For` header if `proxy: true` is configured in the app.
+ // koa will automatically load the `X-Forwarded-For` header if `proxy: true` is configured in the app.
let limitActor: string;
if (user) {
limitActor = user.id;
@@ -255,8 +278,8 @@ export class ApiCallService implements OnApplicationShutdown {
throw new ApiError({
message: 'Your account has been suspended.',
code: 'YOUR_ACCOUNT_SUSPENDED',
+ kind: 'permission',
id: 'a8c724b3-6e9c-4b46-b1a8-bc3ed6258370',
- httpStatusCode: 403,
});
}
}
@@ -266,8 +289,8 @@ export class ApiCallService implements OnApplicationShutdown {
throw new ApiError({
message: 'You have moved your account.',
code: 'YOUR_ACCOUNT_MOVED',
+ kind: 'permission',
id: '56f20ec9-fd06-4fa5-841b-edd6d7d4fa31',
- httpStatusCode: 403,
});
}
}
@@ -321,7 +344,7 @@ export class ApiCallService implements OnApplicationShutdown {
try {
data[k] = JSON.parse(data[k]);
} catch (e) {
- throw new ApiError({
+ throw new ApiError({
message: 'Invalid param.',
code: 'INVALID_PARAM',
id: '0b5f1631-7c1a-41a6-b399-cce335f34d85',
diff --git a/packages/backend/src/server/api/StreamingApiServerService.ts b/packages/backend/src/server/api/StreamingApiServerService.ts
index 8f2e51d584..4a0342d2b4 100644
--- a/packages/backend/src/server/api/StreamingApiServerService.ts
+++ b/packages/backend/src/server/api/StreamingApiServerService.ts
@@ -58,11 +58,21 @@ export class StreamingApiServerService {
let user: LocalUser | null = null;
let app: AccessToken | null = null;
+ // https://datatracker.ietf.org/doc/html/rfc6750.html#section-2.1
+ // Note that the standard WHATWG WebSocket API does not support setting any headers,
+ // but non-browser apps may still be able to set it.
+ const token = request.headers.authorization?.startsWith('Bearer ')
+ ? request.headers.authorization.slice(7)
+ : q.get('i');
+
try {
- [user, app] = await this.authenticateService.authenticate(q.get('i'));
+ [user, app] = await this.authenticateService.authenticate(token);
} catch (e) {
if (e instanceof AuthenticationError) {
- socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
+ socket.write([
+ 'HTTP/1.1 401 Unauthorized',
+ 'WWW-Authenticate: Bearer realm="Misskey", error="invalid_token", error_description="Failed to authenticate"',
+ ].join('\r\n') + '\r\n\r\n');
} else {
socket.write('HTTP/1.1 500 Internal Server Error\r\n\r\n');
}
diff --git a/packages/backend/test/e2e/api.ts b/packages/backend/test/e2e/api.ts
index 4b9167b6b1..c6beec4f88 100644
--- a/packages/backend/test/e2e/api.ts
+++ b/packages/backend/test/e2e/api.ts
@@ -1,9 +1,10 @@
process.env.NODE_ENV = 'test';
import * as assert from 'assert';
-import { signup, api, startServer, successfulApiCall, failedApiCall } from '../utils.js';
+import { signup, api, startServer, successfulApiCall, failedApiCall, uploadFile, waitFire, connectStream } from '../utils.js';
import type { INestApplicationContext } from '@nestjs/common';
import type * as misskey from 'misskey-js';
+import { IncomingMessage } from 'http';
describe('API', () => {
let app: INestApplicationContext;
@@ -123,4 +124,100 @@ describe('API', () => {
id: 'b0a7f5f8-dc2f-4171-b91f-de88ad238e14',
});
});
+
+ describe('Authentication header', () => {
+ test('一般リクエスト', async () => {
+ await successfulApiCall({
+ endpoint: '/admin/get-index-stats',
+ parameters: {},
+ user: {
+ token: alice.token,
+ bearer: true,
+ },
+ });
+ });
+
+ test('multipartリクエスト', async () => {
+ const result = await uploadFile({
+ token: alice.token,
+ bearer: true,
+ });
+ assert.strictEqual(result.status, 200);
+ });
+
+ test('streaming', async () => {
+ const fired = await waitFire(
+ {
+ token: alice.token,
+ bearer: true,
+ },
+ 'homeTimeline',
+ () => api('notes/create', { text: 'foo' }, alice),
+ msg => msg.type === 'note' && msg.body.text === 'foo',
+ );
+ assert.strictEqual(fired, true);
+ });
+ });
+
+ describe('tokenエラー応答でWWW-Authenticate headerを送る', () => {
+ describe('invalid_token', () => {
+ test('一般リクエスト', async () => {
+ const result = await api('/admin/get-index-stats', {}, {
+ token: 'syuilo',
+ bearer: true,
+ });
+ assert.strictEqual(result.status, 401);
+ assert.ok(result.headers.get('WWW-Authenticate')?.startsWith('Bearer realm="Misskey", error="invalid_token", error_description'));
+ });
+
+ test('multipartリクエスト', async () => {
+ const result = await uploadFile({
+ token: 'syuilo',
+ bearer: true,
+ });
+ assert.strictEqual(result.status, 401);
+ assert.ok(result.headers.get('WWW-Authenticate')?.startsWith('Bearer realm="Misskey", error="invalid_token", error_description'));
+ });
+
+ test('streaming', async () => {
+ await assert.rejects(connectStream(
+ {
+ token: 'syuilo',
+ bearer: true,
+ },
+ 'homeTimeline',
+ () => { },
+ ), (err: IncomingMessage) => {
+ assert.strictEqual(err.statusCode, 401);
+ assert.ok(err.headers['www-authenticate']?.startsWith('Bearer realm="Misskey", error="invalid_token", error_description'));
+ return true;
+ });
+ });
+ });
+
+ describe('tokenがないとrealmだけおくる', () => {
+ test('一般リクエスト', async () => {
+ const result = await api('/admin/get-index-stats', {});
+ assert.strictEqual(result.status, 401);
+ assert.strictEqual(result.headers.get('WWW-Authenticate'), 'Bearer realm="Misskey"');
+ });
+
+ test('multipartリクエスト', async () => {
+ const result = await uploadFile();
+ assert.strictEqual(result.status, 401);
+ assert.strictEqual(result.headers.get('WWW-Authenticate'), 'Bearer realm="Misskey"');
+ });
+ });
+
+ test('invalid_request', async () => {
+ const result = await api('/notes/create', { text: true }, {
+ token: alice.token,
+ bearer: true,
+ });
+ assert.strictEqual(result.status, 400);
+ assert.ok(result.headers.get('WWW-Authenticate')?.startsWith('Bearer realm="Misskey", error="invalid_request", error_description'));
+ });
+
+ // TODO: insufficient_scope test (authテストが全然なくて書けない)
+ });
});
diff --git a/packages/backend/test/utils.ts b/packages/backend/test/utils.ts
index 8583f024cb..48947072e3 100644
--- a/packages/backend/test/utils.ts
+++ b/packages/backend/test/utils.ts
@@ -2,7 +2,7 @@ import * as assert from 'node:assert';
import { readFile } from 'node:fs/promises';
import { isAbsolute, basename } from 'node:path';
import { inspect } from 'node:util';
-import WebSocket from 'ws';
+import WebSocket, { ClientOptions } from 'ws';
import fetch, { Blob, File, RequestInit } from 'node-fetch';
import { DataSource } from 'typeorm';
import { JSDOM } from 'jsdom';
@@ -13,7 +13,10 @@ import type * as misskey from 'misskey-js';
export { server as startServer } from '@/boot/common.js';
-interface UserToken { token: string }
+interface UserToken {
+ token: string;
+ bearer?: boolean;
+}
const config = loadConfig();
export const port = config.port;
@@ -57,27 +60,33 @@ export const failedApiCall = async <T, >(request: ApiRequest, assertion: {
return res.body;
};
-const request = async (path: string, params: any, me?: UserToken): Promise<{ body: any, status: number }> => {
- const auth = me ? {
- i: me.token,
- } : {};
+const request = async (path: string, params: any, me?: UserToken): Promise<{ status: number, headers: Headers, body: any }> => {
+ const bodyAuth: Record<string, string> = {};
+ const headers: Record<string, string> = {
+ 'Content-Type': 'application/json',
+ };
+
+ if (me?.bearer) {
+ headers.Authorization = `Bearer ${me.token}`;
+ } else if (me) {
+ bodyAuth.i = me.token;
+ }
const res = await relativeFetch(path, {
method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify(Object.assign(auth, params)),
+ headers,
+ body: JSON.stringify(Object.assign(bodyAuth, params)),
redirect: 'manual',
});
- const status = res.status;
const body = res.headers.get('content-type') === 'application/json; charset=utf-8'
? await res.json()
: null;
return {
- body, status,
+ status: res.status,
+ headers: res.headers,
+ body,
};
};
@@ -241,7 +250,7 @@ interface UploadOptions {
* Upload file
* @param user User
*/
-export const uploadFile = async (user: UserToken, { path, name, blob }: UploadOptions = {}): Promise<any> => {
+export const uploadFile = async (user?: UserToken, { path, name, blob }: UploadOptions = {}): Promise<{ status: number, headers: Headers, body: misskey.Endpoints['drive/files/create']['res'] | null }> => {
const absPath = path == null
? new URL('resources/Lenna.jpg', import.meta.url)
: isAbsolute(path.toString())
@@ -249,7 +258,6 @@ export const uploadFile = async (user: UserToken, { path, name, blob }: UploadOp
: new URL(path, new URL('resources/', import.meta.url));
const formData = new FormData();
- formData.append('i', user.token);
formData.append('file', blob ??
new File([await readFile(absPath)], basename(absPath.toString())));
formData.append('force', 'true');
@@ -257,15 +265,24 @@ export const uploadFile = async (user: UserToken, { path, name, blob }: UploadOp
formData.append('name', name);
}
+ const headers: Record<string, string> = {};
+ if (user?.bearer) {
+ headers.Authorization = `Bearer ${user.token}`;
+ } else if (user) {
+ formData.append('i', user.token);
+ }
+
const res = await relativeFetch('api/drive/files/create', {
method: 'POST',
body: formData,
+ headers,
});
- const body = res.status !== 204 ? await res.json() : null;
+ const body = res.status !== 204 ? await res.json() as misskey.Endpoints['drive/files/create']['res'] : null;
return {
status: res.status,
+ headers: res.headers,
body,
};
};
@@ -294,8 +311,16 @@ export const uploadUrl = async (user: UserToken, url: string) => {
export function connectStream(user: UserToken, channel: string, listener: (message: Record<string, any>) => any, params?: any): Promise<WebSocket> {
return new Promise((res, rej) => {
- const ws = new WebSocket(`ws://127.0.0.1:${port}/streaming?i=${user.token}`);
+ const url = new URL(`ws://127.0.0.1:${port}/streaming`);
+ const options: ClientOptions = {};
+ if (user.bearer) {
+ options.headers = { Authorization: `Bearer ${user.token}` };
+ } else {
+ url.searchParams.set('i', user.token);
+ }
+ const ws = new WebSocket(url, options);
+ ws.on('unexpected-response', (req, res) => rej(res));
ws.on('open', () => {
ws.on('message', data => {
const msg = JSON.parse(data.toString());
diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md
index 5f292148ae..c9b3fd6056 100644
--- a/packages/misskey-js/etc/misskey-js.api.md
+++ b/packages/misskey-js/etc/misskey-js.api.md
@@ -960,8 +960,14 @@ export type Endpoints = {
res: TODO;
};
'drive/files/create': {
- req: TODO;
- res: TODO;
+ req: {
+ folderId?: string;
+ name?: string;
+ comment?: string;
+ isSentisive?: boolean;
+ force?: boolean;
+ };
+ res: DriveFile;
};
'drive/files/delete': {
req: {
@@ -2750,7 +2756,7 @@ type UserSorting = '+follower' | '-follower' | '+createdAt' | '-createdAt' | '+u
//
// src/api.types.ts:16:32 - (ae-forgotten-export) The symbol "TODO" needs to be exported by the entry point index.d.ts
// src/api.types.ts:18:25 - (ae-forgotten-export) The symbol "NoParams" needs to be exported by the entry point index.d.ts
-// src/api.types.ts:611:18 - (ae-forgotten-export) The symbol "ShowUserReq" needs to be exported by the entry point index.d.ts
+// src/api.types.ts:620:18 - (ae-forgotten-export) The symbol "ShowUserReq" needs to be exported by the entry point index.d.ts
// src/streaming.types.ts:33:4 - (ae-forgotten-export) The symbol "FIXME" needs to be exported by the entry point index.d.ts
// (No @packageDocumentation comment for this package)
diff --git a/packages/misskey-js/src/api.types.ts b/packages/misskey-js/src/api.types.ts
index 293e0043b7..93f327e67e 100644
--- a/packages/misskey-js/src/api.types.ts
+++ b/packages/misskey-js/src/api.types.ts
@@ -262,7 +262,16 @@ export type Endpoints = {
'drive/files': { req: { folderId?: DriveFolder['id'] | null; type?: DriveFile['type'] | null; limit?: number; sinceId?: DriveFile['id']; untilId?: DriveFile['id']; }; res: DriveFile[]; };
'drive/files/attached-notes': { req: TODO; res: TODO; };
'drive/files/check-existence': { req: TODO; res: TODO; };
- 'drive/files/create': { req: TODO; res: TODO; };
+ 'drive/files/create': {
+ req: {
+ folderId?: string,
+ name?: string,
+ comment?: string,
+ isSentisive?: boolean,
+ force?: boolean,
+ };
+ res: DriveFile;
+ };
'drive/files/delete': { req: { fileId: DriveFile['id']; }; res: null; };
'drive/files/find-by-hash': { req: TODO; res: TODO; };
'drive/files/find': { req: { name: string; folderId?: DriveFolder['id'] | null; }; res: DriveFile[]; };