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

Callback Endpoint の作成 + Authorize の認証追加 #36

Merged
merged 10 commits into from
Nov 9, 2024
6 changes: 3 additions & 3 deletions webapp/api/index.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { drizzle } from 'drizzle-orm/d1'
import { Hono } from 'hono'
import { secureHeaders } from 'hono/secure-headers'
import { HonoEnv } from 'load-context'

import * as schema from '../db/schema'
import { HonoEnv } from '../load-context'

import cbRoute from './cb'
import goRoute from './go'
import oauthAuthorizeRoute from './oauth/authorize'
import oauthRoute from './oauth'
import tokenRoute from './token'

const app = new Hono<HonoEnv>()
Expand All @@ -30,6 +30,6 @@ app.use(async (c, next) => {
app.route('/token', tokenRoute)
app.route('/go', goRoute)
app.route('/cb', cbRoute)
app.route('/oauth/authorize', oauthAuthorizeRoute)
app.route('/oauth', oauthRoute)

export default app
42 changes: 32 additions & 10 deletions webapp/api/oauth/authorize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { html } from 'hono/html'
import { validator } from 'hono/validator'
import { HonoEnv } from 'load-context'
import { generateAuthToken } from 'utils/auth-token.server'
import cookieSessionStorage from 'utils/session.server'
import { z } from 'zod'

const app = new Hono<HonoEnv>()
Expand Down Expand Up @@ -161,15 +162,11 @@ app.get(
return {
clientId,
redirectUri,
state: state || '',
scope: scope || '',
state,
scope,
clientInfo: client,
}
}),
async (c, next) => {
// TODO: ログインしているかチェック
return next()
},
async c => {
const { clientId, redirectUri, state, scope, clientInfo } =
c.req.valid('query')
Expand All @@ -185,6 +182,32 @@ app.get(
key: privateKey,
})

// ログインしてるか
const { getSession } = cookieSessionStorage(c.env)
const session = await getSession(c.req.raw.headers.get('Cookie'))
const userId = session.get('user_id')
const thisUrl = new URL(c.req.url)
const afterLoginUrl = thisUrl.pathname + thisUrl.search
if (!userId) {
// ログインしてない場合はログイン画面に飛ばす
return c.redirect(
`/login?continue_to=${encodeURIComponent(afterLoginUrl)}`,
302,
)
}
const userInfo = await c.var.dbClient.query.user.findFirst({
where: (user, { eq }) => eq(user.id, userId),
})
if (!userInfo) {
// 存在しないユーザー
// そんなわけないのでログインしなおし
// TODO: login ページでは user_id を消すようにする
return c.redirect(
`/login?continue_to=${encodeURIComponent(afterLoginUrl)}`,
302,
)
}

// TODO: デザインちゃんとする
// とりあえず GitHub OAuth のイメージで書いてる
const responseHtml = html`<!doctype html>
Expand All @@ -196,8 +219,7 @@ app.get(
<h1>${clientInfo.name} を承認しますか?</h1>
<div>
承認すると、 ${clientInfo.owner.displayName} による
${clientInfo.name} はあなたのアカウント
(ここにログインユーザーの情報を入れる)
${clientInfo.name} はあなたのアカウント (${userInfo.displayName})
の以下の情報にアクセスできるようになります。
<ul>
${clientInfo.scopes.map(
Expand All @@ -209,8 +231,8 @@ app.get(
<form method="POST" action="/oauth/callback">
<input type="hidden" name="client_id" value="${clientId}" />
<input type="hidden" name="redirect_uri" value="${redirectUri}" />
<input type="hidden" name="state" value="${state}" />
<input type="hidden" name="scope" value="${scope}" />
<input type="hidden" name="state" value="${state || ''}" />
<input type="hidden" name="scope" value="${scope || ''}" />
a01sa01to marked this conversation as resolved.
Show resolved Hide resolved
<input type="hidden" name="time" value="${nowUnixMs}" />
<input type="hidden" name="auth_token" value="${token}" />
<button type="submit" name="authorized" value="1">承認する</button>
Expand Down
187 changes: 187 additions & 0 deletions webapp/api/oauth/callback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import { zValidator } from '@hono/zod-validator'
import { derivePublicKey, importKey } from '@saitamau-maximum/auth/internal'
import { oauthToken, oauthTokenScope } from 'db/schema'
import { Hono } from 'hono'
import { HonoEnv } from 'load-context'
import { validateAuthToken } from 'utils/auth-token.server'
import cookieSessionStorage from 'utils/session.server'
import { z } from 'zod'

const app = new Hono<HonoEnv>()

// 仕様はここ参照: https://github.com/saitamau-maximum/auth/issues/29

app.post(
'/',
zValidator(
'form',
z.object({
client_id: z.string(),
redirect_uri: z.string().url(),
state: z.string().optional(),
scope: z
.string()
.regex(
/^[\x21|\x23-\x5B|\x5D-\x7E]+(?:\x20+[\x21|\x23-\x5B|\x5D-\x7E]+)*$/,
)
.optional(),
time: z.string().regex(/^\d+$/),
auth_token: z.string().base64(),
authorized: z.literal('1').or(z.literal('0')),
}),
async (res, c) => {
// TODO: いい感じのエラー画面を作るかも
if (!res.success) return c.text('Bad Request: invalid parameters', 400)
},
),
async c => {
const {
auth_token,
authorized,
client_id,
redirect_uri,
time: _time,
scope,
state,
} = c.req.valid('form')
const time = parseInt(_time, 10)
const nowUnixMs = Date.now()

c.header('Cache-Control', 'no-store')
c.header('Pragma', 'no-cache')

const publicKey = await derivePublicKey(
await importKey(c.env.PRIVKEY, 'privateKey'),
)
const isValidToken = await validateAuthToken({
clientId: client_id,
redirectUri: redirect_uri,
scope,
state,
time,
key: publicKey,
hash: auth_token,
})
// auth_token が妥当 = client_id,redirect_uri,time,scope,state がリクエスト時と一致
if (!isValidToken) {
return c.text('Bad Request: invalid auth_token', 400)
}

// ログインしてるか
const { getSession } = cookieSessionStorage(c.env)
const session = await getSession(c.req.raw.headers.get('Cookie'))
const userId = session.get('user_id')
if (!userId) {
// ログインしてない場合は何かがおかしい
return c.text('Bad Request: not logged in', 400)
}
const userInfo = await c.var.dbClient.query.user.findFirst({
where: (user, { eq }) => eq(user.id, userId),
})
if (!userInfo) {
// 存在しないユーザー
// これも何かがおかしい
return c.text('Bad Request: invalid user', 400)
}

// タイムリミットは 5 min
if (time + 5 * 60 * 1000 < nowUnixMs) {
// TODO: 5 min 以内に承認してくださいみたいなメッセージ追加すべき?
return c.text('Bad Request: authorization request expired', 400)
}

const redirectTo = new URL(redirect_uri)
redirectTo.searchParams.append('state', state || '')
if (authorized === '0') {
redirectTo.searchParams.append('error', 'access_denied')
redirectTo.searchParams.append(
'error_description',
'The user denied the request',
)
// redirectTo.searchParams.append('error_uri', '') // そのうち書きたいね
return c.redirect(redirectTo.href, 302)
}

// scope 取得
const requestedScopes = new Set(scope ? scope.split(' ') : [])
const scopes = (
await c.var.dbClient.query.oauthClientScope.findMany({
where: (oauthClientScope, { eq }) =>
eq(oauthClientScope.client_id, client_id),
with: {
scope: true,
},
})
)
.map(clientScope => clientScope.scope)
.filter(data => {
// scope リクエストしてない場合は requestedScopes = [] なので、全部 true として付与
if (requestedScopes.size === 0) return true
// そうでない場合はリクエストされた scope だけを付与
return requestedScopes.has(data.name)
})

// code (240bit = 8bit * 30) を生成
const code = btoa(
String.fromCharCode(...crypto.getRandomValues(new Uint8Array(30))),
)

// access token (312bit = 8bit * 39) を生成
const accessToken = btoa(
String.fromCharCode(...crypto.getRandomValues(new Uint8Array(39))),
)

// DB に格納
// transaction が使えないが、 batch だと autoincrement な token id を取得できないので、 Cloudflare の力を信じてふつうに insert する
const tokenInsertRes = await c.var.dbClient
.insert(oauthToken)
.values({
client_id,
user_id: userId,
code,
code_expires_at: new Date(nowUnixMs + 1 * 60 * 1000), // 1 min
code_used: false,
redirect_uri,
access_token: accessToken,
access_token_expires_at: new Date(nowUnixMs + 1 * 60 * 60 * 1000), // 1 hour
})
.returning()
if (tokenInsertRes.length === 0) {
redirectTo.searchParams.append('error', 'server_error')
redirectTo.searchParams.append(
'error_description',
'Failed to insert token',
)
// redirectTo.searchParams.append('error_uri', '') // そのうち書きたいね
return c.redirect(redirectTo.href, 302)
}
const tokenScopeInsertRes = await c.var.dbClient
.insert(oauthTokenScope)
.values(
scopes.map(scope => ({
token_id: tokenInsertRes[0].id,
scope_id: scope.id,
})),
)
if (!tokenScopeInsertRes.success) {
redirectTo.searchParams.append('error', 'server_error')
redirectTo.searchParams.append(
'error_description',
'Failed to insert token scope',
)
// redirectTo.searchParams.append('error_uri', '') // そのうち書きたいね
return c.redirect(redirectTo.href, 302)
}

redirectTo.searchParams.append('code', code)

return c.redirect(redirectTo.href, 302)
},
)

// POST 以外は許容しない
app.all('/', async c => {
return c.text('method not allowed', 405)
})

export default app
13 changes: 13 additions & 0 deletions webapp/api/oauth/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Hono } from 'hono'

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

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

const app = new Hono<HonoEnv>()

app.route('/authorize', oauthAuthorizeRoute)
app.route('/callback', oauthCallbackRoute)

export default app
14 changes: 7 additions & 7 deletions webapp/utils/auth-token.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
interface Param {
clientId: string
redirectUri: string
state: string
scope: string
state?: string
scope?: string
time: number
}

Expand All @@ -22,8 +22,8 @@ const content = (param: Param) => {
const p = new URLSearchParams()
p.append('client_id', param.clientId)
p.append('redirect_uri', param.redirectUri)
p.append('state', param.state)
p.append('scope', param.scope)
if (param.state) p.append('state', param.state)
if (param.scope) p.append('scope', param.scope)
p.append('time', param.time.toString())
return new TextEncoder().encode(p.toString())
}
Expand All @@ -36,15 +36,15 @@ const ALG = {
export const generateAuthToken = async (param: GenerateParam) => {
const { key, ...rest } = param
const signedBuf = await crypto.subtle.sign(ALG, key, content(rest))
return btoa(Array.from(new Uint8Array(signedBuf)).join(','))
return btoa(String.fromCharCode(...new Uint8Array(signedBuf)))
}

export const validateAuthToken = (param: ValidateParam) => {
const { key, hash, ...rest } = param
const signBuf = new Uint8Array(
atob(hash)
.split(',')
.map(byte => parseInt(byte, 10)),
.split('')
.map(c => c.charCodeAt(0)),
)
return crypto.subtle.verify(ALG, key, signBuf, content(rest))
}