Skip to content

Commit

Permalink
Token Endpoint の作成 (#42)
Browse files Browse the repository at this point in the history
* fix: redirect_uri を null 許容

* `/access-token` 実装

* fix failed batch
  • Loading branch information
a01sa01to authored Nov 13, 2024
1 parent 1715777 commit f843414
Show file tree
Hide file tree
Showing 9 changed files with 636 additions and 12 deletions.
181 changes: 181 additions & 0 deletions webapp/api/oauth/accessToken.ts
Original file line number Diff line number Diff line change
@@ -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<HonoEnv>()

// 仕様はここ参照: 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
20 changes: 14 additions & 6 deletions webapp/api/oauth/authorize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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)
Expand All @@ -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()
Expand All @@ -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)
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -217,7 +225,7 @@ app.get(
})),
oauthFields: {
clientId,
redirectUri,
redirectUri: redirectTo,
state,
scope,
token,
Expand Down
20 changes: 18 additions & 2 deletions webapp/api/oauth/callback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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')
Expand Down
2 changes: 2 additions & 0 deletions webapp/api/oauth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@ import { Hono } from 'hono'

import { HonoEnv } from '../../load-context'

import oauthAccesstokenRoute from './accessToken'
import oauthAuthorizeRoute from './authorize'
import oauthCallbackRoute from './callback'

const app = new Hono<HonoEnv>()

app.route('/authorize', oauthAuthorizeRoute)
app.route('/callback', oauthCallbackRoute)
app.route('/access-token', oauthAccesstokenRoute)

export default app
5 changes: 3 additions & 2 deletions webapp/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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 }) => ({
Expand Down
20 changes: 20 additions & 0 deletions webapp/drizzle/0002_nasty_winter_soldier.sql
Original file line number Diff line number Diff line change
@@ -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`);
Loading

0 comments on commit f843414

Please sign in to comment.