Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Token Endpoint の作成 #42

Merged
merged 3 commits into from
Nov 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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