diff --git a/webapp/api/oauth/accessToken.ts b/webapp/api/oauth/accessToken.ts new file mode 100644 index 0000000..8c0144b --- /dev/null +++ b/webapp/api/oauth/accessToken.ts @@ -0,0 +1,181 @@ +import { zValidator } from '@hono/zod-validator' +import { token, tokenScope } from 'db/schema' +import { eq } from 'drizzle-orm' +import { Hono } from 'hono' +import { HonoEnv } from 'load-context' +import { z } from 'zod' + +const app = new Hono() + +// 仕様はここ参照: https://github.com/saitamau-maximum/auth/issues/29 + +app.post( + '/', + async (c, next) => { + // もし Authorization ヘッダーがある場合は 401 を返す + const authHeader = c.req.header('Authorization') + if (authHeader) { + return c.json( + { + error: 'invalid_request', + error_description: 'Authorization header is not allowed', + // "error_uri": "" // そのうち書く + }, + 401, + ) + } + return next() + }, + zValidator( + 'form', + z.object({ + grant_type: z.string(), + code: z.string(), + redirect_uri: z.string().url().optional(), + client_id: z.string(), + client_secret: z.string(), + }), + async (res, c) => { + // TODO: いい感じのエラー画面を作るかも + if (!res.success) + return c.json( + { + error: 'invalid_request', + error_description: 'Invalid Parameters', + // "error_uri": "" // そのうち書く + }, + 400, + ) + }, + ), + async c => { + const { client_id, client_secret, code, redirect_uri, grant_type } = + c.req.valid('form') + + const nowUnixMs = Date.now() + const nowDate = new Date(nowUnixMs) + + const tokenInfo = await c.var.dbClient.query.token.findFirst({ + where: (token, { eq, and, gt }) => + and(eq(token.code, code), gt(token.code_expires_at, nowDate)), + with: { + client: { + with: { + secrets: { + where: (secret, { eq }) => eq(secret.secret, client_secret), + }, + }, + }, + scopes: { + with: { + scope: true, + }, + }, + }, + }) + + c.header('Cache-Control', 'no-store') + c.header('Pragma', 'no-cache') + + // Token が見つからない場合 + if (!tokenInfo) { + return c.json( + { + error: 'invalid_grant', + error_description: 'Invalid Code (Not Found, Expired, etc)', + // "error_uri": "" // そのうち書く + }, + 401, + ) + } + + // redirect_uri 一致チェック + if ( + (redirect_uri && tokenInfo.redirect_uri !== redirect_uri) || + (!redirect_uri && tokenInfo.redirect_uri) + ) { + return c.json( + { + error: 'invalid_request', + error_description: 'Redirect URI mismatch', + // "error_uri": "" // そのうち書く + }, + 400, + ) + } + + // client id, secret のペアが存在するかチェック + if ( + tokenInfo.client.id !== client_id || + tokenInfo.client.secrets.length === 0 + ) { + return c.json( + { + error: 'invalid_client', + error_description: 'Invalid client_id or client_secret', + // "error_uri": "" // そのうち書く + }, + 401, + ) + } + + // grant_type チェック + if (grant_type !== 'authorization_code') { + return c.json( + { + error: 'unsupported_grant_type', + error_description: 'grant_type must be authorization_code', + // "error_uri": "" // そのうち書く + }, + 400, + ) + } + + // もしすでに token が使われていた場合 + if (tokenInfo.code_used) { + // そのレコードを削除 + // 失敗していても response は変わらないので無視 + await c.var.dbClient.batch([ + // これ順番逆にすると外部キー制約で落ちるよ (戒め) + c.var.dbClient + .delete(tokenScope) + .where(eq(tokenScope.token_id, tokenInfo.id)), + c.var.dbClient.delete(token).where(eq(token.id, tokenInfo.id)), + ]) + return c.json( + { + error: 'invalid_grant', + error_description: 'Invalid Code (Already Used)', + // "error_uri": "" // そのうち書く + }, + 401, + ) + } + + // token が使われたことを記録 + await c.var.dbClient + .update(token) + .set({ code_used: true }) + .where(eq(token.id, tokenInfo.id)) + + // token の残り時間を計算 + const remMs = tokenInfo.code_expires_at.getTime() - nowUnixMs + + return c.json( + { + access_token: tokenInfo.access_token, + token_type: 'bearer', + expires_in: Math.floor(remMs / 1000), + scope: tokenInfo.scopes.map(s => s.scope.name).join(' '), + }, + 200, + ) + }, +) + +// POST 以外は許容しない +app.all('/', async c => { + return c.text('method not allowed', 405) +}) + +export default app diff --git a/webapp/api/oauth/authorize.ts b/webapp/api/oauth/authorize.ts index 8bd2458..db155ef 100644 --- a/webapp/api/oauth/authorize.ts +++ b/webapp/api/oauth/authorize.ts @@ -40,12 +40,14 @@ app.get( if (!client) return c.text('Bad Request: client_id not registered', 400) // redirect_uri が複数ないことをチェック - // eslint-disable-next-line prefer-const - let { data: redirectUri, success: success2 } = z + // redirectUri: パラメータで指定されたやつ、 null 許容 + // redirectTo: 最終的にリダイレクトするやつ、 non-null + const { data: redirectUri, success: success2 } = z .string() .url() .optional() .safeParse(query['redirect_uri']) + let redirectTo: string = redirectUri || '' if (!success2) { return c.text('Bad Request: invalid redirect_uri', 400) } @@ -60,7 +62,7 @@ app.get( } // DB 内に登録されているものを callback として扱う - redirectUri = client.callbacks[0].callback_url + redirectTo = client.callbacks[0].callback_url } else { // Redirect URI のクエリパラメータ部分は変わることを許容する const normalizedUri = new URL(redirectUri) @@ -75,6 +77,11 @@ app.get( } } + // redirectTo !== "" を assert + if (redirectTo === '') { + return c.text('Internal Server Error: redirect_uri is empty', 500) + } + const { data: state, success: success3 } = z .string() .optional() @@ -90,7 +97,7 @@ app.get( description: string, _errorUri: string, ) => { - const callback = new URL(redirectUri) + const callback = new URL(redirectTo) callback.searchParams.append('error', error) callback.searchParams.append('error_description', description) @@ -163,13 +170,14 @@ app.get( return { clientId, redirectUri, + redirectTo, state, scope, clientInfo: client, } }), async c => { - const { clientId, redirectUri, state, scope, clientInfo } = + const { clientId, redirectUri, redirectTo, state, scope, clientInfo } = c.req.valid('query') const nowUnixMs = Date.now() @@ -217,7 +225,7 @@ app.get( })), oauthFields: { clientId, - redirectUri, + redirectUri: redirectTo, state, scope, token, diff --git a/webapp/api/oauth/callback.ts b/webapp/api/oauth/callback.ts index e98c252..eeec72d 100644 --- a/webapp/api/oauth/callback.ts +++ b/webapp/api/oauth/callback.ts @@ -17,7 +17,7 @@ app.post( 'form', z.object({ client_id: z.string(), - redirect_uri: z.string().url(), + redirect_uri: z.string().url().optional(), state: z.string().optional(), scope: z .string() @@ -88,7 +88,23 @@ app.post( return c.text('Bad Request: authorization request expired', 400) } - const redirectTo = new URL(redirect_uri) + let redirectTo: URL + if (redirect_uri) { + redirectTo = new URL(redirect_uri) + } else { + // DB から読み込み + // `/authorize` 側で client_id に対応する callback_url は必ず存在して 1 つだけであることを保証している + const clientCallback = + await c.var.dbClient.query.clientCallback.findFirst({ + where: (clientCallback, { eq }) => + eq(clientCallback.client_id, client_id), + }) + if (!clientCallback) { + return c.text('Internal Server Error: client callback not found', 500) + } + redirectTo = new URL(clientCallback.callback_url) + } + redirectTo.searchParams.append('state', state || '') if (authorized === '0') { redirectTo.searchParams.append('error', 'access_denied') diff --git a/webapp/api/oauth/index.ts b/webapp/api/oauth/index.ts index ae940c7..2675403 100644 --- a/webapp/api/oauth/index.ts +++ b/webapp/api/oauth/index.ts @@ -2,6 +2,7 @@ import { Hono } from 'hono' import { HonoEnv } from '../../load-context' +import oauthAccesstokenRoute from './accessToken' import oauthAuthorizeRoute from './authorize' import oauthCallbackRoute from './callback' @@ -9,5 +10,6 @@ const app = new Hono() app.route('/authorize', oauthAuthorizeRoute) app.route('/callback', oauthCallbackRoute) +app.route('/access-token', oauthAccesstokenRoute) export default app diff --git a/webapp/db/schema.ts b/webapp/db/schema.ts index bc7f95b..83f870c 100644 --- a/webapp/db/schema.ts +++ b/webapp/db/schema.ts @@ -69,7 +69,7 @@ export const token = sqliteTable('token', { code: text('code').notNull().unique(), code_expires_at: int('code_expires_at', { mode: 'timestamp_ms' }).notNull(), code_used: int('code_used', { mode: 'boolean' }).notNull(), - redirect_uri: text('redirect_uri').notNull(), + redirect_uri: text('redirect_uri'), access_token: text('access_token').notNull().unique(), access_token_expires_at: int('access_token_expires_at', { mode: 'timestamp_ms', @@ -129,11 +129,12 @@ export const clientScopeRelations = relations(clientScope, ({ one }) => ({ }), })) -export const tokenRelations = relations(token, ({ one }) => ({ +export const tokenRelations = relations(token, ({ one, many }) => ({ client: one(client, { fields: [token.client_id], references: [client.id], }), + scopes: many(tokenScope), })) export const tokenScopeRelations = relations(tokenScope, ({ one }) => ({ diff --git a/webapp/drizzle/0002_nasty_winter_soldier.sql b/webapp/drizzle/0002_nasty_winter_soldier.sql new file mode 100644 index 0000000..27d758e --- /dev/null +++ b/webapp/drizzle/0002_nasty_winter_soldier.sql @@ -0,0 +1,20 @@ +PRAGMA foreign_keys=OFF;--> statement-breakpoint +CREATE TABLE `__new_token` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `client_id` text NOT NULL, + `user_id` text NOT NULL, + `code` text NOT NULL, + `code_expires_at` integer NOT NULL, + `code_used` integer NOT NULL, + `redirect_uri` text, + `access_token` text NOT NULL, + `access_token_expires_at` integer NOT NULL, + FOREIGN KEY (`client_id`) REFERENCES `client`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +INSERT INTO `__new_token`("id", "client_id", "user_id", "code", "code_expires_at", "code_used", "redirect_uri", "access_token", "access_token_expires_at") SELECT "id", "client_id", "user_id", "code", "code_expires_at", "code_used", "redirect_uri", "access_token", "access_token_expires_at" FROM `token`;--> statement-breakpoint +DROP TABLE `token`;--> statement-breakpoint +ALTER TABLE `__new_token` RENAME TO `token`;--> statement-breakpoint +PRAGMA foreign_keys=ON;--> statement-breakpoint +CREATE UNIQUE INDEX `token_code_unique` ON `token` (`code`);--> statement-breakpoint +CREATE UNIQUE INDEX `token_access_token_unique` ON `token` (`access_token`); \ No newline at end of file diff --git a/webapp/drizzle/meta/0002_snapshot.json b/webapp/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..7eaf6a4 --- /dev/null +++ b/webapp/drizzle/meta/0002_snapshot.json @@ -0,0 +1,389 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "3b31c93b-85a3-47a9-8059-9249076b1364", + "prevId": "94763c92-4e0a-41d2-8b8c-5c54c96a1303", + "tables": { + "client": { + "name": "client", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "client_callback": { + "name": "client_callback", + "columns": { + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "callback_url": { + "name": "callback_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "client_callback_client_id_client_id_fk": { + "name": "client_callback_client_id_client_id_fk", + "tableFrom": "client_callback", + "tableTo": "client", + "columnsFrom": ["client_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "client_callback_client_id_callback_url_pk": { + "columns": ["client_id", "callback_url"], + "name": "client_callback_client_id_callback_url_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "client_scope": { + "name": "client_scope", + "columns": { + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope_id": { + "name": "scope_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "client_scope_client_id_client_id_fk": { + "name": "client_scope_client_id_client_id_fk", + "tableFrom": "client_scope", + "tableTo": "client", + "columnsFrom": ["client_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "client_scope_scope_id_scope_id_fk": { + "name": "client_scope_scope_id_scope_id_fk", + "tableFrom": "client_scope", + "tableTo": "scope", + "columnsFrom": ["scope_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "client_scope_client_id_scope_id_pk": { + "columns": ["client_id", "scope_id"], + "name": "client_scope_client_id_scope_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "client_secret": { + "name": "client_secret", + "columns": { + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "issued_by": { + "name": "issued_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "issued_at": { + "name": "issued_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "client_secret_client_id_client_id_fk": { + "name": "client_secret_client_id_client_id_fk", + "tableFrom": "client_secret", + "tableTo": "client", + "columnsFrom": ["client_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "client_secret_client_id_secret_pk": { + "columns": ["client_id", "secret"], + "name": "client_secret_client_id_secret_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "scope": { + "name": "scope", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "scope_name_unique": { + "name": "scope_name_unique", + "columns": ["name"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "token": { + "name": "token", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "code_expires_at": { + "name": "code_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "code_used": { + "name": "code_used", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "redirect_uri": { + "name": "redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "token_code_unique": { + "name": "token_code_unique", + "columns": ["code"], + "isUnique": true + }, + "token_access_token_unique": { + "name": "token_access_token_unique", + "columns": ["access_token"], + "isUnique": true + } + }, + "foreignKeys": { + "token_client_id_client_id_fk": { + "name": "token_client_id_client_id_fk", + "tableFrom": "token", + "tableTo": "client", + "columnsFrom": ["client_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "token_scope": { + "name": "token_scope", + "columns": { + "token_id": { + "name": "token_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope_id": { + "name": "scope_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "token_scope_token_id_token_id_fk": { + "name": "token_scope_token_id_token_id_fk", + "tableFrom": "token_scope", + "tableTo": "token", + "columnsFrom": ["token_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "token_scope_scope_id_scope_id_fk": { + "name": "token_scope_scope_id_scope_id_fk", + "tableFrom": "token_scope", + "tableTo": "scope", + "columnsFrom": ["scope_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "token_scope_token_id_scope_id_pk": { + "columns": ["token_id", "scope_id"], + "name": "token_scope_token_id_scope_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/webapp/drizzle/meta/_journal.json b/webapp/drizzle/meta/_journal.json index 01ea4b7..72e1602 100644 --- a/webapp/drizzle/meta/_journal.json +++ b/webapp/drizzle/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1731299340259, "tag": "0001_bent_king_bedlam", "breakpoints": true + }, + { + "idx": 2, + "version": "6", + "when": 1731487271125, + "tag": "0002_nasty_winter_soldier", + "breakpoints": true } ] } diff --git a/webapp/utils/auth-token.server.ts b/webapp/utils/auth-token.server.ts index ca1c74d..9536873 100644 --- a/webapp/utils/auth-token.server.ts +++ b/webapp/utils/auth-token.server.ts @@ -3,7 +3,7 @@ interface Param { clientId: string - redirectUri: string + redirectUri?: string state?: string scope?: string time: number @@ -21,7 +21,7 @@ interface ValidateParam extends Param { const content = (param: Param) => { const p = new URLSearchParams() p.append('client_id', param.clientId) - p.append('redirect_uri', param.redirectUri) + if (param.redirectUri) p.append('redirect_uri', param.redirectUri) if (param.state) p.append('state', param.state) if (param.scope) p.append('scope', param.scope) p.append('time', param.time.toString())