diff --git a/README.md b/README.md index a4b67a2..efde6f5 100644 --- a/README.md +++ b/README.md @@ -115,16 +115,6 @@ if (!validation) { JS/TS 以外の言語を使いたい場合、 `package/src/validate.ts` を参考にしてください。 -### ログイン状態の確認 - -```javascript -import { checkLoggedIn } from '@saitamau-maximum/auth' - -await checkLoggedIn(request, publicKey) // => true/false -``` - -ユーザー情報の取得と合わせてログイン状態の確認を行うことも可能 (↓) - ### ユーザー情報の取得 ```javascript @@ -141,7 +131,7 @@ const userinfo = await getUserInfo(request, options) ``` クライアントサイドで取得する場合、 `/auth/me` にリクエストを送ってください。 -秘密鍵など漏洩の可能性があるため、 `getUserInfo` は使わないようにしてください。 +秘密鍵など漏洩の可能性があるため、クライアントサイドで `getUserInfo` は使わないようにしてください。 ### ログイン diff --git a/package/package.json b/package/package.json index a682811..d40a908 100644 --- a/package/package.json +++ b/package/package.json @@ -37,6 +37,6 @@ }, "dependencies": { "cookie": "^0.6.0", - "dayjs": "^1.11.13" + "jose": "^5.9.3" } } diff --git a/package/src/index.ts b/package/src/index.ts index b89ed08..8f54a4b 100644 --- a/package/src/index.ts +++ b/package/src/index.ts @@ -1,3 +1,3 @@ -export { UserInfo, checkLoggedIn, getUserInfo } from './userinfo' +export { UserInfo, getUserInfo } from './userinfo' export { middleware } from './middleware' export { validateRequest } from './validate' diff --git a/package/src/internal/goparam.ts b/package/src/internal/goparam.ts deleted file mode 100644 index f059ea8..0000000 --- a/package/src/internal/goparam.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { sign, verify } from './keygen' - -const generateGoParamBase = ( - name: string, - pubkey: string, - callback: string, - token: string, - iv: string, -) => { - const param = new URLSearchParams() - param.set('name', name) - param.set('pubkey', pubkey) - param.set('callback', callback) - param.set('token', token) - param.set('iv', iv) - return param -} - -export const generateGoParam = async ( - name: string, - pubkey: string, - callback: string, - token: string, - iv: string, - privateKey: CryptoKey, -) => { - const baseParam = generateGoParamBase(name, pubkey, callback, token, iv) - const mac = await sign(baseParam.toString(), privateKey) - baseParam.set('mac', mac) - return baseParam.toString() -} - -export const verifyMac = async ( - name: string, - pubkey: string, - callback: string, - token: string, - iv: string, - mac: string, - trustedPubkey: CryptoKey, -) => { - const baseParam = generateGoParamBase(name, pubkey, callback, token, iv) - try { - return await verify(baseParam.toString(), mac, trustedPubkey) - } catch (_) { - return false - } -} diff --git a/package/src/internal/handleCallback.ts b/package/src/internal/handleCallback.ts index 6e21d51..5ec5282 100644 --- a/package/src/internal/handleCallback.ts +++ b/package/src/internal/handleCallback.ts @@ -1,10 +1,13 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion */ import { parse as parseCookie, serialize as serializeCookie } from 'cookie' +import { jwtVerify, SignJWT } from 'jose' + +import { UserInfo } from '../userinfo' import { AUTH_PUBKEY } from './const' import { cookieOptions } from './cookie' -import { importKey, sign, verify } from './keygen' +import { importKey, keypairProtectedHeader } from './keygen' interface Options { /** @@ -188,11 +191,7 @@ export const handleCallback = async ( }) } - if ( - ['authdata', 'iv', 'signature', 'signatureIv'].some( - key => !param.has(key) || param.getAll(key).length !== 1, - ) - ) { + if (!param.has('token')) { return new Response('invalid request', { status: 400 }) } @@ -205,19 +204,32 @@ export const handleCallback = async ( options.authPubkey || AUTH_PUBKEY, 'publicKey', ) - const privateKey = await importKey(options.privateKey, 'privateKey') - - if ( - !(await verify( - param.get('authdata') as string, - param.get('signature') as string, - authPubkey, - )) || - !(await verify(param.get('iv')!, param.get('signatureIv')!, authPubkey)) - ) { - return new Response('invalid signature', { status: 400 }) + const { payload } = await jwtVerify( + param.get('token')!, + authPubkey, + { + algorithms: [keypairProtectedHeader.alg], + audience: options.authName, + clockTolerance: 5, + issuer: 'maximum-auth', + subject: 'Maximum Auth Data', + }, + ).catch(() => ({ payload: null })) + if (!payload) { + return new Response('invalid token', { status: 400 }) } + const privateKey = await importKey(options.privateKey, 'privateKey') + const newJwt = await new SignJWT(payload) + .setSubject('Maximum Auth Data') + .setAudience(options.authName) + .setIssuer(options.authName) + .setNotBefore('0 sec') + .setIssuedAt() + .setExpirationTime('1 day') + .setProtectedHeader(keypairProtectedHeader) + .sign(privateKey) + const continueUrl = parseCookie(cookieData)['__continue_to'] const newHeader = new Headers(request.headers) @@ -230,31 +242,7 @@ export const handleCallback = async ( ) newHeader.append( 'Set-Cookie', - serializeCookie('__authdata', param.get('authdata')!, cookieOptions), - ) - newHeader.append( - 'Set-Cookie', - serializeCookie('__iv', param.get('iv')!, cookieOptions), - ) - newHeader.append( - 'Set-Cookie', - serializeCookie('__sign1', param.get('signature')!, cookieOptions), - ) - newHeader.append( - 'Set-Cookie', - serializeCookie( - '__sign2', - await sign(param.get('authdata')!, privateKey), - cookieOptions, - ), - ) - newHeader.append( - 'Set-Cookie', - serializeCookie( - '__sign3', - await sign(param.get('iv')!, privateKey), - cookieOptions, - ), + serializeCookie('token', newJwt, cookieOptions), ) newHeader.set('Location', continueUrl || '/') diff --git a/package/src/internal/handleLogin.ts b/package/src/internal/handleLogin.ts index ffbc7b7..8c429b7 100644 --- a/package/src/internal/handleLogin.ts +++ b/package/src/internal/handleLogin.ts @@ -2,7 +2,6 @@ import { serialize as serializeCookie } from 'cookie' import { AUTH_DOMAIN } from './const' import { cookieOptions } from './cookie' -import { generateGoParam } from './goparam' import { derivePublicKey, exportKey, importKey } from './keygen' const usedCharacters = Array.from( @@ -170,7 +169,7 @@ export const handleLogin = async ( const privkey = await importKey(options.privateKey, 'privateKey') const pubkey = await derivePublicKey(privkey) - const redirectData = await fetch(`${authOrigin}/token`, { + const token = await fetch(`${authOrigin}/token`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -180,23 +179,21 @@ export const handleLogin = async ( pubkey: await exportKey(pubkey), callback: callbackUrl, }), - }) - .then(res => res.json<{ token: string; iv: string }>()) - .catch(() => { - throw new Error('auth server error', { - cause: - 'authName or privateKey is incorrect. The auth server might be down.', - }) + }).then(res => { + if (res.ok) { + return res.text() + } + throw new Error('auth server error', { + cause: + 'authName or privateKey is incorrect. The auth server might be down.', }) + }) - const param = await generateGoParam( - options.authName, - await exportKey(pubkey), - callbackUrl, - atob(redirectData.token), - atob(redirectData.iv), - privkey, - ) + const param = new URLSearchParams() + param.set('name', options.authName) + param.set('pubkey', await exportKey(pubkey)) + param.set('callback', callbackUrl) + param.set('token', token) return new Response(null, { status: 302, diff --git a/package/src/internal/handleLogout.ts b/package/src/internal/handleLogout.ts index d5f33f0..a9280ff 100644 --- a/package/src/internal/handleLogout.ts +++ b/package/src/internal/handleLogout.ts @@ -103,35 +103,7 @@ export const handleLogout = async (request: Request): Promise => { const newHeader = new Headers(request.headers) newHeader.append( 'Set-Cookie', - serializeCookie('__authdata', '', { - ...cookieOptions, - maxAge: -1, - }), - ) - newHeader.append( - 'Set-Cookie', - serializeCookie('__iv', '', { - ...cookieOptions, - maxAge: -1, - }), - ) - newHeader.append( - 'Set-Cookie', - serializeCookie('__sign1', '', { - ...cookieOptions, - maxAge: -1, - }), - ) - newHeader.append( - 'Set-Cookie', - serializeCookie('__sign2', '', { - ...cookieOptions, - maxAge: -1, - }), - ) - newHeader.append( - 'Set-Cookie', - serializeCookie('__sign3', '', { + serializeCookie('token', '', { ...cookieOptions, maxAge: -1, }), diff --git a/package/src/internal/index.ts b/package/src/internal/index.ts index 0824283..69c20d3 100644 --- a/package/src/internal/index.ts +++ b/package/src/internal/index.ts @@ -1,21 +1,17 @@ export { AUTH_DOMAIN, AUTH_PUBKEY, PROXY_PUBKEY } from './const' export { - decrypt, derivePublicKey, - encrypt, exportKey, generateKeyPair, generateSymmetricKey, importKey, keypairGenAlgorithm, - keypairHashAlgorithm, + keypairProtectedHeader, keypairUsage, - sign, symmetricGenAlgorithm, + symmetricProtectedHeader, symmetricUsage, - verify, } from './keygen' -export { generateGoParam, verifyMac } from './goparam' export { generateToken, verifyToken } from './tokengen' export { handleCallback } from './handleCallback' export { handleLogin } from './handleLogin' diff --git a/package/src/internal/keygen.ts b/package/src/internal/keygen.ts index 7c3b158..281f5d5 100644 --- a/package/src/internal/keygen.ts +++ b/package/src/internal/keygen.ts @@ -2,17 +2,20 @@ const keypairGenAlgorithm = { name: 'ECDSA', namedCurve: 'P-521', } -const keypairHashAlgorithm = { - name: 'ECDSA', - hash: 'SHA-512', -} const keypairUsage = ['sign', 'verify'] +const keypairProtectedHeader = { + alg: 'ES512', +} const symmetricGenAlgorithm = { name: 'AES-GCM', length: 256, } const symmetricUsage = ['encrypt', 'decrypt'] +const symmetricProtectedHeader = { + alg: 'dir', + enc: 'A256GCM', +} const generateKeyPair = () => crypto.subtle.generateKey( @@ -29,7 +32,7 @@ const generateSymmetricKey = () => ) as Promise const exportKey = async (key: CryptoKey) => { - const exportedKey = await crypto.subtle.exportKey('jwk', key) + const exportedKey = (await crypto.subtle.exportKey('jwk', key)) as JsonWebKey return btoa(JSON.stringify(exportedKey)) } @@ -38,7 +41,6 @@ const importKey = async ( type: 'publicKey' | 'privateKey' | 'symmetric', ) => { const keyData = JSON.parse(atob(data)) - if (type === 'symmetric') { return crypto.subtle.importKey( 'jwk', @@ -47,15 +49,14 @@ const importKey = async ( true, symmetricUsage, ) - } else { - return crypto.subtle.importKey( - 'jwk', - keyData, - keypairGenAlgorithm, - true, - type === 'publicKey' ? ['verify'] : ['sign'], - ) } + return crypto.subtle.importKey( + 'jwk', + keyData, + keypairGenAlgorithm, + true, + type === 'publicKey' ? ['verify'] : ['sign'], + ) } const derivePublicKey = async (privateKey: CryptoKey) => { @@ -68,82 +69,16 @@ const derivePublicKey = async (privateKey: CryptoKey) => { return importKey(btoa(JSON.stringify(publicKey)), 'publicKey') } -const sign = async (data: string, privateKey: CryptoKey) => { - const dataBuf = new TextEncoder().encode(data) - const resBuf = await crypto.subtle.sign( - keypairHashAlgorithm, - privateKey, - dataBuf, - ) - return btoa(Array.from(new Uint8Array(resBuf)).join(',')) -} - -const verify = async ( - data: string, - signature: string, - publicKey: CryptoKey, -) => { - const dataBuf = new TextEncoder().encode(data) - const signBuf = new Uint8Array( - atob(signature) - .split(',') - .map(byte => parseInt(byte, 10)), - ) - return crypto.subtle.verify(keypairHashAlgorithm, publicKey, signBuf, dataBuf) -} - -const encrypt = async ( - data: string, - key: CryptoKey, -): Promise<[string, string]> => { - const iv = crypto.getRandomValues(new Uint8Array(12)) - const dataBuf = new TextEncoder().encode(data) - const encrypted = await crypto.subtle.encrypt( - { name: 'AES-GCM', iv }, - key, - dataBuf, - ) - - return [ - btoa(Array.from(new Uint8Array(encrypted)).join(',')), - btoa(Array.from(iv).join(',')), - ] -} - -const decrypt = async (data: string, key: CryptoKey, iv: string) => { - return new TextDecoder().decode( - await crypto.subtle.decrypt( - { - name: 'AES-GCM', - iv: new Uint8Array( - atob(iv) - .split(',') - .map(byte => parseInt(byte, 10)), - ), - }, - key, - new Uint8Array( - atob(data) - .split(',') - .map(byte => parseInt(byte, 10)), - ), - ), - ) -} - export { - decrypt, derivePublicKey, - encrypt, exportKey, generateKeyPair, generateSymmetricKey, importKey, keypairGenAlgorithm, - keypairHashAlgorithm, + keypairProtectedHeader, keypairUsage, - sign, symmetricGenAlgorithm, + symmetricProtectedHeader, symmetricUsage, - verify, } diff --git a/package/src/internal/tokengen.ts b/package/src/internal/tokengen.ts index 7ec67a5..0db2d23 100644 --- a/package/src/internal/tokengen.ts +++ b/package/src/internal/tokengen.ts @@ -1,65 +1,68 @@ -import dayjs from 'dayjs' -import timezone from 'dayjs/plugin/timezone' -import utc from 'dayjs/plugin/utc' +import { EncryptJWT, jwtDecrypt } from 'jose' -import { decrypt, encrypt } from './keygen' +import { symmetricProtectedHeader } from './keygen' -dayjs.extend(utc) -dayjs.extend(timezone) -dayjs.tz.setDefault('Asia/Tokyo') +interface ITokenPayload { + name: string + pubkey: string + callback: string +} + +const subj = 'Maximum Auth Token' +const aud = 'maximum-auth' +const iss = 'maximum-auth' -export const generateToken = async ( - name: string, - pubkey: string, - callback: string, - key: CryptoKey, -) => { - const now = dayjs.tz().valueOf() - const param = new URLSearchParams() - param.set('user', name) - param.set('pubkey', pubkey) - param.set('callback', callback) - param.set('time', String(now)) - const tokenData = btoa(param.toString()) - return await encrypt(tokenData, key) +export const generateToken = async ({ + name, + pubkey, + callback, + symkey, +}: { + name: string + pubkey: string + callback: string + symkey: CryptoKey +}) => { + return await new EncryptJWT({ name, pubkey, callback }) + .setSubject(subj) + .setAudience(aud) + .setIssuer(iss) + .setNotBefore('0 sec') + .setIssuedAt() + .setExpirationTime('10 sec') + .setProtectedHeader(symmetricProtectedHeader) + .encrypt(symkey) } -export const verifyToken = async ( - name: string, - pubkey: string, - callback: string, - key: CryptoKey, - token: string, - iv: string, -): Promise<[boolean, string]> => { - let decrypted: string - try { - decrypted = await decrypt(token, key, iv) - } catch (_) { - return [false, 'invalid token'] - } - const tokenData = atob(decrypted) - const data = new URLSearchParams(tokenData) +export const verifyToken = async ({ + name, + pubkey, + callback, + symkey, + token, +}: { + name: string | null + pubkey: string | null + callback: string | null + symkey: CryptoKey + token: string | null +}): Promise<[boolean, string]> => { + if (!name || !pubkey || !callback || !token) return [false, 'invalid request'] + + const { payload } = await jwtDecrypt(token, symkey, { + subject: subj, + audience: aud, + issuer: iss, + clockTolerance: 5, + }).catch(() => ({ payload: null })) + if (!payload) return [false, 'invalid token'] if ( - ['user', 'pubkey', 'callback', 'time'].some( - key => !data.has(key) || data.getAll(key).length !== 1, - ) + payload.name !== name || + payload.pubkey !== pubkey || + payload.callback !== callback ) return [false, 'invalid token'] - const user = data.get('user') - if (user !== name) return [false, 'user mismatch'] - - const pubkeyData = data.get('pubkey') - if (pubkeyData !== pubkey) return [false, 'pubkey mismatch'] - - const callbackData = data.get('callback') - if (callbackData !== callback) return [false, 'callback mismatch'] - - const time = data.get('time') - if (dayjs.tz().diff(dayjs.tz(Number(time)), 'millisecond') > 10000) - return [false, 'token expired'] - return [true, 'valid token'] } diff --git a/package/src/middleware.ts b/package/src/middleware.ts index 561c999..7550225 100644 --- a/package/src/middleware.ts +++ b/package/src/middleware.ts @@ -1,12 +1,5 @@ -import { - derivePublicKey, - handleCallback, - handleLogin, - handleLogout, - handleMe, - importKey, -} from './internal' -import { checkLoggedIn } from './userinfo' +import { handleCallback, handleLogin, handleLogout, handleMe } from './internal' +import { getUserInfo } from './userinfo' interface Env { AUTH_NAME: string @@ -24,8 +17,6 @@ const middleware: PagesFunction = async context => { } const reqUrl = new URL(context.request.url) - const privkey = await importKey(context.env.PRIVKEY, 'privateKey') - const pubkey = await derivePublicKey(privkey) const isDev = context.env.IS_DEV === 'true' if (reqUrl.pathname.startsWith('/auth/')) { @@ -63,7 +54,14 @@ const middleware: PagesFunction = async context => { return new Response('not found', { status: 404 }) } - if (!(await checkLoggedIn(context.request, pubkey, isDev))) { + const userInfo = await getUserInfo(context.request, { + authName: context.env.AUTH_NAME, + privateKey: context.env.PRIVKEY, + authOrigin: context.env.AUTH_DOMAIN, + dev: isDev, + }) + + if (!userInfo) { return handleLogin(context.request, { authName: context.env.AUTH_NAME, privateKey: context.env.PRIVKEY, diff --git a/package/src/userinfo.ts b/package/src/userinfo.ts index 2ac954b..cdb3d3d 100644 --- a/package/src/userinfo.ts +++ b/package/src/userinfo.ts @@ -1,19 +1,7 @@ import { parse as parseCookie } from 'cookie' -import dayjs from 'dayjs' -import timezone from 'dayjs/plugin/timezone' -import utc from 'dayjs/plugin/utc' +import { jwtVerify } from 'jose' -import { - AUTH_DOMAIN, - derivePublicKey, - exportKey, - importKey, - verify, -} from './internal' - -dayjs.extend(utc) -dayjs.extend(timezone) -dayjs.tz.setDefault('Asia/Tokyo') +import { derivePublicKey, importKey, keypairProtectedHeader } from './internal' interface Options { /** @@ -45,43 +33,6 @@ interface UserInfo { profile_image: string teams: string[] is_member: boolean - - time: number -} - -const checkLoggedIn = async ( - request: Request, - publicKey: CryptoKey | null, - isDev?: boolean, -) => { - const cookie = parseCookie(request.headers.get('Cookie') || '') - - if (isDev) { - return !!cookie['__dev_logged_in'] - } else if (publicKey === null) { - throw new Error('publicKey が null です') - } - - if ( - !cookie['__authdata'] || - !cookie['__iv'] || - !cookie['__sign1'] || - !cookie['__sign2'] || - !cookie['__sign3'] - ) { - return false - } - - // ほんとはいつ認証したかについてもチェックすべきかもだが、 - // サブリクエスト数が多くなっても困るので簡易的にチェック - const authdata = cookie['__authdata'] - const iv = cookie['__iv'] - const sig = cookie['__sign2'] - const sigIv = cookie['__sign3'] - return ( - (await verify(authdata, sig, publicKey)) && - (await verify(iv, sigIv, publicKey)) - ) } const getUserInfo = async ( @@ -94,10 +45,10 @@ const getUserInfo = async ( } } + const cookie = parseCookie(request.headers.get('Cookie') || '') + if (options.dev) { - if (!(await checkLoggedIn(request, null, true))) { - return null - } + if (!cookie['__dev_logged_in']) return null const DUMMY_USERDATA: UserInfo = { id: '120705481', @@ -105,7 +56,6 @@ const getUserInfo = async ( is_member: true, profile_image: 'https://avatars.githubusercontent.com/u/120705481?v=4', teams: ['leaders'], - time: dayjs.tz().valueOf(), } return DUMMY_USERDATA @@ -114,41 +64,17 @@ const getUserInfo = async ( const privateKey = await importKey(options.privateKey, 'privateKey') const publicKey = await derivePublicKey(privateKey) - if (await checkLoggedIn(request, publicKey)) { - // checkLoggedIn で Cookie があることを前提としている - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const cookie = parseCookie(request.headers.get('Cookie')!) + if (!cookie['token']) return null - const postData = { - name: options.authName, - pubkey: await exportKey(publicKey), - data: cookie['__authdata'], - iv: cookie['__iv'], - sgn1: cookie['__sign1'], - sgn2: cookie['__sign2'], - sgn3: cookie['__sign3'], - } + const { payload } = await jwtVerify(cookie['token'], publicKey, { + algorithms: [keypairProtectedHeader.alg], + audience: options.authName, + issuer: options.authName, + subject: 'Maximum Auth Data', + clockTolerance: 5, + }).catch(() => ({ payload: null })) - const authOrigin = options.authOrigin || AUTH_DOMAIN - - const res = await fetch(`${authOrigin}/user`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(postData), - }) - - if (res.status === 200) { - const data = await res.json() - if (!data.is_member) return null - return data - } else { - console.error(res.status, res.statusText) - return null - } - } - return null + return payload } -export { UserInfo, checkLoggedIn, getUserInfo } +export { UserInfo, getUserInfo } diff --git a/package/src/validate.ts b/package/src/validate.ts index abc1a49..4166043 100644 --- a/package/src/validate.ts +++ b/package/src/validate.ts @@ -1,14 +1,8 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion */ -import dayjs from 'dayjs' -import timezone from 'dayjs/plugin/timezone' -import utc from 'dayjs/plugin/utc' +import { jwtVerify } from 'jose' -import { importKey, PROXY_PUBKEY, verify } from './internal' - -dayjs.extend(utc) -dayjs.extend(timezone) -dayjs.tz.setDefault('Asia/Tokyo') +import { importKey, keypairProtectedHeader, PROXY_PUBKEY } from './internal' interface Options { proxyPubkey?: string @@ -18,12 +12,9 @@ const validateRequest = async (header: Headers, options?: Options) => { const proxyPubkey = options?.proxyPubkey || PROXY_PUBKEY if ( - [ - 'X-Maximum-Auth-Pubkey', - 'X-Maximum-Auth-Time', - 'X-Maximum-Auth-Key', - 'X-Maximum-Auth-Mac', - ].some(key => !header.has(key)) + ['X-Maximum-Auth-Pubkey', 'X-Maximum-Auth-Token'].some( + key => !header.has(key), + ) ) { return false } @@ -32,21 +23,16 @@ const validateRequest = async (header: Headers, options?: Options) => { return false } - const time = dayjs.tz(parseInt(header.get('X-Maximum-Auth-Time')!)) - const now = dayjs.tz() - - // 一応時計のずれも考慮しておく - if (Math.abs(now.diff(time, 'second')) > 15) { - return false - } - const pubkey = await importKey(proxyPubkey, 'publicKey') - return await verify( - `${header.get('X-Maximum-Auth-Time')!}___${header.get('X-Maximum-Auth-Key')!}`, - header.get('X-Maximum-Auth-Mac')!, - pubkey, - ) + return await jwtVerify(header.get('X-Maximum-Auth-Token')!, pubkey, { + algorithms: [keypairProtectedHeader.alg], + issuer: 'maximum-auth-proxy', + subject: 'Maximum Auth Proxy', + clockTolerance: 5, + }) + .then(() => true) + .catch(() => false) } export { validateRequest } diff --git a/package/test/internal/goparam.test.ts b/package/test/internal/goparam.test.ts deleted file mode 100644 index 220eaa7..0000000 --- a/package/test/internal/goparam.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -/* eslint-disable @typescript-eslint/no-non-null-assertion */ - -import { describe, expect, it } from 'vitest' - -import { generateGoParam, verifyMac } from '../../src/internal/goparam' -import { generateKeyPair } from '../../src/internal/keygen' - -describe('correctly works', () => { - const getRandomString = () => Math.random().toString(36).substring(7) - - it('generates a go param', async () => { - const name = getRandomString() - const pubkey = getRandomString() - const callback = getRandomString() - const token = getRandomString() - const iv = getRandomString() - const { privateKey } = await generateKeyPair() - const result = await generateGoParam( - name, - pubkey, - callback, - token, - iv, - privateKey, - ) - expect(result).toBeTypeOf('string') - }) - - it('verifies a go param', async () => { - const name = getRandomString() - const pubkey = getRandomString() - const callback = getRandomString() - const token = getRandomString() - const iv = getRandomString() - const { privateKey, publicKey } = await generateKeyPair() - const mac = new URLSearchParams( - await generateGoParam(name, pubkey, callback, token, iv, privateKey), - ).get('mac') - expect(mac).not.toBe(null) - const result = await verifyMac( - name, - pubkey, - callback, - token, - iv, - mac!, - publicKey, - ) - expect(result).toBe(true) - }) - - it('verifies a go param with wrong mac', async () => { - const name = getRandomString() - const pubkey = getRandomString() - const callback = getRandomString() - const token = getRandomString() - const iv = getRandomString() - const { privateKey, publicKey } = await generateKeyPair() - const mac = - new URLSearchParams( - await generateGoParam(name, pubkey, callback, token, iv, privateKey), - ).get('mac') + 'a' - const result = await verifyMac( - name, - pubkey, - callback, - token, - iv, - mac, - publicKey, - ) - expect(result).toBe(false) - }) -}) diff --git a/package/test/internal/handleCallback.test.ts b/package/test/internal/handleCallback.test.ts index 9ce9642..ebe3467 100644 --- a/package/test/internal/handleCallback.test.ts +++ b/package/test/internal/handleCallback.test.ts @@ -1,13 +1,18 @@ /* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */ +// jsdom だと TextEncoder と Uint8Array の互換性がないみたいなので node でテストする (挙動は同じ...はず) +// https://github.com/vitest-dev/vitest/issues/4043 +// @vitest-environment node + +import { jwtVerify, SignJWT } from 'jose' import { describe, expect, it, vi } from 'vitest' import { handleCallback } from '../../src/internal/handleCallback' import { - derivePublicKey, + exportKey, + generateKeyPair, importKey, - sign, - verify, + keypairProtectedHeader, } from '../../src/internal/keygen' import { cookieParser, removesCookie } from './cookieUtil' @@ -30,6 +35,29 @@ const TEST_PUBKEY = const TEST_PRIVKEY = 'eyJrdHkiOiJFQyIsImtleV9vcHMiOlsic2lnbiJdLCJleHQiOnRydWUsImNydiI6IlAtNTIxIiwieCI6IkFmRVhoOTY5VTRxMUo3RDNZQlBpMFY2ODlPdUFaOVJGZS1STHRhSFE4QmUwTEQ2LWQ5dlJ1ZEFFMnlHTE10Z0lMX1drekhSRF9TNjg2M1BkUUZKLTJydnEiLCJ5IjoiQUtpZ0Ftd2FPcF9Vd3V2NXZqOEE4UXFjS2Z3WG42enZNUHU3QWhOdkFDdE5qdC1UdTBvRWg3d0Z5dGJDR3FNaFRYMm01SEJnZlZhbTh5aTRMWkRiSWlYcCIsImQiOiJBVVJnWGpLazBtaDlsbDdtVDZvRDJTT09TZEF1bWpITFQtOU1pZnRsd1VNOGpiTlNRMkJxOVlBemNvVkZRRmV5VzEzbVNzY3dUT0dUbTRxZ1QwWDJnV1VmIn0=' +const generateJWT = async ({ + privateKey, + subject = 'Maximum Auth Data', + audience = 'test', + issuer = 'maximum-auth', + exp = '1 day', + ...payload +}: { + privateKey: CryptoKey + subject?: string + audience?: string + issuer?: string + exp?: string + [key: string]: unknown +}) => { + let jwt = new SignJWT(payload).setNotBefore('0 sec').setIssuedAt() + if (subject) jwt = jwt.setSubject(subject) + if (audience) jwt = jwt.setAudience(audience) + if (issuer) jwt = jwt.setIssuer(issuer) + if (exp) jwt = jwt.setExpirationTime(exp) + return await jwt.setProtectedHeader(keypairProtectedHeader).sign(privateKey) +} + describe('required options missing', () => { it('throws when options.authName is not provided', async () => { const req = new Request('https://example.com', { method: 'GET' }) @@ -182,83 +210,13 @@ describe('dev mode', () => { }) describe('prod mode', () => { - it('returns 400 when authdata is missing', async () => { - const param = new URLSearchParams() - param.set('iv', 'test') - param.set('signature', 'test') - param.set('signatureIv', 'test') - const req = new Request( - 'http://localhost/auth/callback?' + param.toString(), - { - method: 'GET', - headers: { - Cookie: '__continue_to=http://localhost/', - }, - }, - ) - const res = await handleCallback(req, { - authName: 'test', - privateKey: 'test', - }) - expect(res).toHaveProperty('status', 400) - }) - - it('returns 400 when iv is missing', async () => { - const param = new URLSearchParams() - param.set('authdata', 'test') - param.set('signature', 'test') - param.set('signatureIv', 'test') - const req = new Request( - 'http://localhost/auth/callback?' + param.toString(), - { - method: 'GET', - headers: { - Cookie: '__continue_to=http://localhost/', - }, - }, - ) - const res = await handleCallback(req, { - authName: 'test', - privateKey: 'test', - }) - expect(res).toHaveProperty('status', 400) - }) - - it('returns 400 when signature is missing', async () => { - const param = new URLSearchParams() - param.set('authdata', 'test') - param.set('iv', 'test') - param.set('signatureIv', 'test') - const req = new Request( - 'http://localhost/auth/callback?' + param.toString(), - { - method: 'GET', - headers: { - Cookie: '__continue_to=http://localhost/', - }, + it('returns 400 when token is missing', async () => { + const req = new Request('http://localhost/auth/callback?', { + method: 'GET', + headers: { + Cookie: '__continue_to=http://localhost/', }, - ) - const res = await handleCallback(req, { - authName: 'test', - privateKey: 'test', }) - expect(res).toHaveProperty('status', 400) - }) - - it('returns 400 when signatureIv is missing', async () => { - const param = new URLSearchParams() - param.set('authdata', 'test') - param.set('iv', 'test') - param.set('signature', 'test') - const req = new Request( - 'http://localhost/auth/callback?' + param.toString(), - { - method: 'GET', - headers: { - Cookie: '__continue_to=http://localhost/', - }, - }, - ) const res = await handleCallback(req, { authName: 'test', privateKey: 'test', @@ -268,10 +226,7 @@ describe('prod mode', () => { it('returns 400 when cookie is not set', async () => { const param = new URLSearchParams() - param.set('authdata', 'test') - param.set('iv', 'test') - param.set('signature', 'test') - param.set('signatureIv', 'test') + param.set('token', 'test') const req = new Request( 'http://localhost/auth/callback?' + param.toString(), { @@ -285,14 +240,16 @@ describe('prod mode', () => { expect(res).toHaveProperty('status', 400) }) - it('returns 400 when authdata does not match signature', async () => { + it('returns 400 when audience is not authname', async () => { const authPrivkey = await importKey(TEST_AUTHPRIVKEY, 'privateKey') + const token = await generateJWT({ + privateKey: authPrivkey, + audience: 'test1', + }) const param = new URLSearchParams() - param.set('authdata', 'test1') - param.set('iv', 'test2') - param.set('signature', await sign('test', authPrivkey)) - param.set('signatureIv', await sign('test2', authPrivkey)) + param.set('token', token) + const req = new Request( 'http://localhost/auth/callback?' + param.toString(), { @@ -309,14 +266,16 @@ describe('prod mode', () => { expect(res).toHaveProperty('status', 400) }) - it('returns 400 when iv does not match signatureIv', async () => { + it('returns 400 when issuer is not "maximum-auth"', async () => { const authPrivkey = await importKey(TEST_AUTHPRIVKEY, 'privateKey') + const token = await generateJWT({ + privateKey: authPrivkey, + issuer: 'test', + }) const param = new URLSearchParams() - param.set('authdata', 'test1') - param.set('iv', 'test2') - param.set('signature', await sign('test1', authPrivkey)) - param.set('signatureIv', await sign('test', authPrivkey)) + param.set('token', token) + const req = new Request( 'http://localhost/auth/callback?' + param.toString(), { @@ -333,39 +292,16 @@ describe('prod mode', () => { expect(res).toHaveProperty('status', 400) }) - it('uses options.authPubkey if set', async () => { - const authPrivkey = await importKey(TEST_PRIVKEY, 'privateKey') - - const param = new URLSearchParams() - param.set('authdata', 'test1') - param.set('iv', 'test2') - param.set('signature', await sign('test1', authPrivkey)) - param.set('signatureIv', await sign('test2', authPrivkey)) - const req = new Request( - 'http://localhost/auth/callback?' + param.toString(), - { - method: 'GET', - headers: { - Cookie: '__continue_to=http://localhost/', - }, - }, - ) - const res = await handleCallback(req, { - authName: 'test', - privateKey: TEST_PRIVKEY, - authPubkey: TEST_PUBKEY, - }) - expect(res).toHaveProperty('status', 302) - }) - - it("removes '__continue_to' cookie", async () => { + it('returns 400 when subject is not "Maximum Auth Data"', async () => { const authPrivkey = await importKey(TEST_AUTHPRIVKEY, 'privateKey') + const token = await generateJWT({ + privateKey: authPrivkey, + subject: 'test', + }) const param = new URLSearchParams() - param.set('authdata', 'test1') - param.set('iv', 'test2') - param.set('signature', await sign('test1', authPrivkey)) - param.set('signatureIv', await sign('test2', authPrivkey)) + param.set('token', token) + const req = new Request( 'http://localhost/auth/callback?' + param.toString(), { @@ -377,48 +313,24 @@ describe('prod mode', () => { ) const res = await handleCallback(req, { authName: 'test', - privateKey: TEST_PRIVKEY, + privateKey: TEST_AUTHPRIVKEY, }) - expect(res.headers.has('Set-Cookie')).toBeTruthy() - const cookie = cookieParser(res.headers.get('Set-Cookie')!) - expect(removesCookie(cookie, '__continue_to')).toBeTruthy() + expect(res).toHaveProperty('status', 400) }) - it("adds '__authdata' cookie", async () => { + it('returns 400 when token is expired', async () => { const authPrivkey = await importKey(TEST_AUTHPRIVKEY, 'privateKey') + const token = await generateJWT({ + privateKey: authPrivkey, + exp: '1 sec', + }) const param = new URLSearchParams() - param.set('authdata', 'test1') - param.set('iv', 'test2') - param.set('signature', await sign('test1', authPrivkey)) - param.set('signatureIv', await sign('test2', authPrivkey)) - const req = new Request( - 'http://localhost/auth/callback?' + param.toString(), - { - method: 'GET', - headers: { - Cookie: '__continue_to=http://localhost/', - }, - }, - ) - const res = await handleCallback(req, { - authName: 'test', - privateKey: TEST_PRIVKEY, - }) - expect(res.headers.has('Set-Cookie')).toBeTruthy() - const cookie = cookieParser(res.headers.get('Set-Cookie')!) - expect(cookie.has('__authdata')).toBeTruthy() - expect(cookie.get('__authdata')![0]).toBe('test1') - }) + param.set('token', token) - it("adds '__iv' cookie", async () => { - const authPrivkey = await importKey(TEST_AUTHPRIVKEY, 'privateKey') + // 5 sec の tolerance 考慮 + await new Promise(resolve => setTimeout(resolve, 7000)) - const param = new URLSearchParams() - param.set('authdata', 'test1') - param.set('iv', 'test2') - param.set('signature', await sign('test1', authPrivkey)) - param.set('signatureIv', await sign('test2', authPrivkey)) const req = new Request( 'http://localhost/auth/callback?' + param.toString(), { @@ -430,23 +342,18 @@ describe('prod mode', () => { ) const res = await handleCallback(req, { authName: 'test', - privateKey: TEST_PRIVKEY, + privateKey: TEST_AUTHPRIVKEY, }) - expect(res.headers.has('Set-Cookie')).toBeTruthy() - const cookie = cookieParser(res.headers.get('Set-Cookie')!) - expect(cookie.has('__iv')).toBeTruthy() - expect(cookie.get('__iv')![0]).toBe('test2') - }) + expect(res).toHaveProperty('status', 400) + }, 19000) - it("adds '__sign1' cookie", async () => { - const authPrivkey = await importKey(TEST_AUTHPRIVKEY, 'privateKey') - const authPubkey = await derivePublicKey(authPrivkey) + it('uses options.authPubkey if set', async () => { + const { privateKey, publicKey } = await generateKeyPair() + const token = await generateJWT({ privateKey }) const param = new URLSearchParams() - param.set('authdata', 'test1') - param.set('iv', 'test2') - param.set('signature', await sign('test1', authPrivkey)) - param.set('signatureIv', await sign('test2', authPrivkey)) + param.set('token', token) + const req = new Request( 'http://localhost/auth/callback?' + param.toString(), { @@ -459,24 +366,18 @@ describe('prod mode', () => { const res = await handleCallback(req, { authName: 'test', privateKey: TEST_PRIVKEY, + authPubkey: await exportKey(publicKey), }) - expect(res.headers.has('Set-Cookie')).toBeTruthy() - const cookie = cookieParser(res.headers.get('Set-Cookie')!) - expect(cookie.has('__sign1')).toBeTruthy() - // timingSafeEqual が使えないので、 verify で検証 - expect( - await verify('test1', cookie.get('__sign1')![0], authPubkey), - ).toBeTruthy() + expect(res).toHaveProperty('status', 302) }) - it("adds '__sign2' cookie", async () => { + it("removes '__continue_to' cookie", async () => { const authPrivkey = await importKey(TEST_AUTHPRIVKEY, 'privateKey') + const token = await generateJWT({ privateKey: authPrivkey }) const param = new URLSearchParams() - param.set('authdata', 'test1') - param.set('iv', 'test2') - param.set('signature', await sign('test1', authPrivkey)) - param.set('signatureIv', await sign('test2', authPrivkey)) + param.set('token', token) + const req = new Request( 'http://localhost/auth/callback?' + param.toString(), { @@ -492,21 +393,18 @@ describe('prod mode', () => { }) expect(res.headers.has('Set-Cookie')).toBeTruthy() const cookie = cookieParser(res.headers.get('Set-Cookie')!) - expect(cookie.has('__sign2')).toBeTruthy() - const testPubkey = await importKey(TEST_PUBKEY, 'publicKey') - expect( - await verify('test1', cookie.get('__sign2')![0], testPubkey), - ).toBeTruthy() + expect(removesCookie(cookie, '__continue_to')).toBeTruthy() }) - it("adds '__sign3' cookie", async () => { + it("adds 'token' cookie", async () => { const authPrivkey = await importKey(TEST_AUTHPRIVKEY, 'privateKey') + const payload = { hoge: 'fuga' } + const token = await generateJWT({ privateKey: authPrivkey, ...payload }) + const param = new URLSearchParams() - param.set('authdata', 'test1') - param.set('iv', 'test2') - param.set('signature', await sign('test1', authPrivkey)) - param.set('signatureIv', await sign('test2', authPrivkey)) + param.set('token', token) + const req = new Request( 'http://localhost/auth/callback?' + param.toString(), { @@ -522,21 +420,33 @@ describe('prod mode', () => { }) expect(res.headers.has('Set-Cookie')).toBeTruthy() const cookie = cookieParser(res.headers.get('Set-Cookie')!) - expect(cookie.has('__sign3')).toBeTruthy() - const testPubkey = await importKey(TEST_PUBKEY, 'publicKey') - expect( - await verify('test2', cookie.get('__sign3')![0], testPubkey), - ).toBeTruthy() + expect(cookie.has('token')).toBeTruthy() + + const pubkey = await importKey(TEST_PUBKEY, 'publicKey') + const jwt = cookie.get('token')![0] + const { payload: resPayload } = await jwtVerify(jwt, pubkey, { + algorithms: [keypairProtectedHeader.alg], + audience: 'test', + clockTolerance: 5, + issuer: 'test', + subject: 'Maximum Auth Data', + }).catch(() => ({ payload: null })) + + // jwt 用のプロパティ (iat, exp) などが追加されているので、 deepEqual は使わない + expect(resPayload).not.toBeNull() + for (const key in payload) { + expect(resPayload).toHaveProperty(key) + expect(resPayload![key]).toBe(payload[key]) + } }) it("redirects to '__continue_to'", async () => { const authPrivkey = await importKey(TEST_AUTHPRIVKEY, 'privateKey') + const token = await generateJWT({ privateKey: authPrivkey }) const param = new URLSearchParams() - param.set('authdata', 'test1') - param.set('iv', 'test2') - param.set('signature', await sign('test1', authPrivkey)) - param.set('signatureIv', await sign('test2', authPrivkey)) + param.set('token', token) + const req = new Request( 'http://localhost/auth/callback?' + param.toString(), { @@ -556,12 +466,11 @@ describe('prod mode', () => { it("redirects to root when '__continue_to' is not set", async () => { const authPrivkey = await importKey(TEST_AUTHPRIVKEY, 'privateKey') + const token = await generateJWT({ privateKey: authPrivkey }) const param = new URLSearchParams() - param.set('authdata', 'test1') - param.set('iv', 'test2') - param.set('signature', await sign('test1', authPrivkey)) - param.set('signatureIv', await sign('test2', authPrivkey)) + param.set('token', token) + const req = new Request( 'http://localhost/auth/callback?' + param.toString(), { diff --git a/package/test/internal/handleLogin.test.ts b/package/test/internal/handleLogin.test.ts index 5d9d6b2..dccaf66 100644 --- a/package/test/internal/handleLogin.test.ts +++ b/package/test/internal/handleLogin.test.ts @@ -1,5 +1,9 @@ /* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */ +// jsdom だと TextEncoder と Uint8Array の互換性がないみたいなので node でテストする (挙動は同じ...はず) +// https://github.com/vitest-dev/vitest/issues/4043 +// @vitest-environment node + import type { MockInstance } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -119,15 +123,13 @@ describe('prod mode', () => { expect(data.name).toBeTypeOf('string') expect(data.pubkey).toBeTypeOf('string') expect(data.callback).toBeTypeOf('string') - return new Response( - JSON.stringify({ token: btoa('token'), iv: btoa('iv') }), - { - status: 200, - headers: { - 'Content-Type': 'application/json', - }, + + return new Response('DUMMY_TOKEN', { + status: 200, + headers: { + 'Content-Type': 'text/plain', }, - ) + }) } if (path === 'https://auth.server.test.dummy/token') { @@ -191,17 +193,13 @@ describe('prod mode', () => { expect(params.get('name')).toBe('test') // expect(params.get('pubkey')).toBe(TEST_PUBKEY) - // 鍵の key の順番が違うせい?でうまくいかないので値として等しいかチェック + // derivePubkey で鍵の key の順番が違うせい?でうまくいかないので値として等しいかチェック expect(await importKey(TEST_PUBKEY, 'publicKey')).toEqual( await importKey(params.get('pubkey')!, 'publicKey'), ) expect(params.get('callback')).toBe('https://example.com/auth/callback') - expect(params.get('token')).toBe('token') - expect(params.get('iv')).toBe('iv') - - // めんどくさいので mac の値自体は検証しない - expect(params.get('mac')).toBeTruthy() + expect(params.get('token')).toBe('DUMMY_TOKEN') }) it('works if authOrigin is missing', async () => { diff --git a/package/test/internal/handleLogout.test.ts b/package/test/internal/handleLogout.test.ts index 7aedca9..f7615d5 100644 --- a/package/test/internal/handleLogout.test.ts +++ b/package/test/internal/handleLogout.test.ts @@ -7,44 +7,12 @@ import { handleLogout } from '../../src/internal/handleLogout' import { cookieParser, removesCookie } from './cookieUtil' describe('works', () => { - it("removes '__authdata' cookie", async () => { + it("removes 'token' cookie", async () => { const req = new Request('https://example.com/auth/logout') const res = await handleLogout(req) expect(res.headers.has('Set-Cookie')).toBeTruthy() const cookie = cookieParser(res.headers.get('Set-Cookie')!) - expect(removesCookie(cookie, '__authdata')).toBeTruthy() - }) - - it("removes '__iv' cookie", async () => { - const req = new Request('https://example.com/auth/logout') - const res = await handleLogout(req) - expect(res.headers.has('Set-Cookie')).toBeTruthy() - const cookie = cookieParser(res.headers.get('Set-Cookie')!) - expect(removesCookie(cookie, '__iv')).toBeTruthy() - }) - - it("removes '__sign1' cookie", async () => { - const req = new Request('https://example.com/auth/logout') - const res = await handleLogout(req) - expect(res.headers.has('Set-Cookie')).toBeTruthy() - const cookie = cookieParser(res.headers.get('Set-Cookie')!) - expect(removesCookie(cookie, '__sign1')).toBeTruthy() - }) - - it("removes '__sign2' cookie", async () => { - const req = new Request('https://example.com/auth/logout') - const res = await handleLogout(req) - expect(res.headers.has('Set-Cookie')).toBeTruthy() - const cookie = cookieParser(res.headers.get('Set-Cookie')!) - expect(removesCookie(cookie, '__sign2')).toBeTruthy() - }) - - it("removes '__sign3' cookie", async () => { - const req = new Request('https://example.com/auth/logout') - const res = await handleLogout(req) - expect(res.headers.has('Set-Cookie')).toBeTruthy() - const cookie = cookieParser(res.headers.get('Set-Cookie')!) - expect(removesCookie(cookie, '__sign3')).toBeTruthy() + expect(removesCookie(cookie, 'token')).toBeTruthy() }) it("removes '__continue_to' cookie", async () => { diff --git a/package/test/internal/handleMe.test.ts b/package/test/internal/handleMe.test.ts index 2374b1a..aad27b7 100644 --- a/package/test/internal/handleMe.test.ts +++ b/package/test/internal/handleMe.test.ts @@ -1,16 +1,14 @@ -/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */ -import dayjs from 'dayjs' -import timezone from 'dayjs/plugin/timezone' -import utc from 'dayjs/plugin/utc' -import type { MockInstance } from 'vitest' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +/* eslint-disable @typescript-eslint/no-explicit-any */ -import { handleMe } from '../../src/internal/handleMe' -import { importKey, sign } from '../../src/internal/keygen' +// jsdom だと TextEncoder と Uint8Array の互換性がないみたいなので node でテストする (挙動は同じ...はず) +// https://github.com/vitest-dev/vitest/issues/4043 +// @vitest-environment node + +import { SignJWT } from 'jose' +import { describe, expect, it } from 'vitest' -dayjs.extend(utc) -dayjs.extend(timezone) -dayjs.tz.setDefault('Asia/Tokyo') +import { handleMe } from '../../src/internal/handleMe' +import { importKey, keypairProtectedHeader } from '../../src/internal/keygen' const TEST_PRIVKEY = 'eyJrdHkiOiJFQyIsImtleV9vcHMiOlsic2lnbiJdLCJleHQiOnRydWUsImNydiI6IlAtNTIxIiwieCI6IkFmRVhoOTY5VTRxMUo3RDNZQlBpMFY2ODlPdUFaOVJGZS1STHRhSFE4QmUwTEQ2LWQ5dlJ1ZEFFMnlHTE10Z0lMX1drekhSRF9TNjg2M1BkUUZKLTJydnEiLCJ5IjoiQUtpZ0Ftd2FPcF9Vd3V2NXZqOEE4UXFjS2Z3WG42enZNUHU3QWhOdkFDdE5qdC1UdTBvRWg3d0Z5dGJDR3FNaFRYMm01SEJnZlZhbTh5aTRMWkRiSWlYcCIsImQiOiJBVVJnWGpLazBtaDlsbDdtVDZvRDJTT09TZEF1bWpITFQtOU1pZnRsd1VNOGpiTlNRMkJxOVlBemNvVkZRRmV5VzEzbVNzY3dUT0dUbTRxZ1QwWDJnV1VmIn0=' @@ -63,82 +61,10 @@ describe('dev mode', () => { 'https://avatars.githubusercontent.com/u/120705481?v=4', ) expect(json).toHaveProperty('teams', ['leaders']) - expect(json).toHaveProperty('time') - // 1ms 以内の誤差を許容 - expect(dayjs.tz().valueOf() - json.time).toBeLessThan(1000) }) }) describe('prod mode', () => { - let mockedFetch: MockInstance< - [input: string | Request | URL, init?: RequestInit | undefined], - Promise - > - - beforeEach(() => { - mockedFetch = vi - .spyOn(global, 'fetch') - .mockImplementation(async (path, options) => { - // webapp/app/routes/user.tsx - if ( - path === 'https://auth.server.test/user' || - path === 'https://auth.maximum.vc/user' - ) { - expect(options).toBeTruthy() - expect(options!.method).toBe('POST') - expect(options!.headers).toBeTruthy() - const headers = new Headers(options!.headers) - expect(headers.get('content-type')).toBeTruthy() - expect( - headers.get('content-type')!.includes('application/json'), - ).toBeTruthy() - expect(options!.body).toBeTruthy() - await expect( - new Promise((resolve, reject) => { - try { - JSON.parse(options!.body as string) - resolve(true) - } catch (e) { - reject(e) - } - }), - ).resolves.toBeTruthy() - const data = JSON.parse(options!.body as string) - expect(data.name).toBeTypeOf('string') - expect(data.pubkey).toBeTypeOf('string') - expect(data.data).toBeTypeOf('string') - expect(data.iv).toBeTypeOf('string') - expect(data.sgn1).toBeTypeOf('string') - expect(data.sgn2).toBeTypeOf('string') - expect(data.sgn3).toBeTypeOf('string') - return new Response( - JSON.stringify({ - id: '120705481', - display_name: 'saitamau-maximum', - is_member: true, - profile_image: - 'https://avatars.githubusercontent.com/u/120705481?v=4', - teams: ['leaders'], - time: dayjs.tz().valueOf(), - }), - { - status: 200, - headers: { - 'Content-Type': 'application/json', - }, - }, - ) - } - - console.error('unexpected fetch', path) - return new Response(null, { status: 500 }) - }) - }) - - afterEach(() => { - mockedFetch.mockRestore() - }) - it('returns 401 when not logged in', async () => { const req = new Request('http://localhost/auth/me') const res = await handleMe(req, { @@ -163,10 +89,20 @@ describe('prod mode', () => { it('returns 200 when logged in', async () => { const privkey = await importKey(TEST_PRIVKEY, 'privateKey') + const payload = { hoge: 'fuga' } + const token = await new SignJWT(payload) + .setSubject('Maximum Auth Data') + .setAudience('test') + .setIssuer('test') + .setNotBefore('0 sec') + .setIssuedAt() + .setExpirationTime('1 day') + .setProtectedHeader(keypairProtectedHeader) + .sign(privkey) const req = new Request('http://localhost/auth/me', { headers: { - Cookie: `__authdata=test;__iv=ivtest;__sign1=hoge;__sign2=${await sign('test', privkey)};__sign3=${await sign('ivtest', privkey)}`, + Cookie: `token=${token}`, }, }) const res = await handleMe(req, { @@ -177,18 +113,33 @@ describe('prod mode', () => { expect(res.status).toBe(200) }) - it('works if authOrigin is missing', async () => { + it('returns 401 when token is expired', async () => { const privkey = await importKey(TEST_PRIVKEY, 'privateKey') + const payload = { hoge: 'fuga' } + const token = await new SignJWT(payload) + .setSubject('Maximum Auth Data') + .setAudience('test') + .setIssuer('test') + .setNotBefore('0 sec') + .setIssuedAt() + .setExpirationTime('1 sec') + .setProtectedHeader(keypairProtectedHeader) + .sign(privkey) + + // tolerance 5 sec を考慮 + await new Promise(resolve => setTimeout(resolve, 7000)) const req = new Request('http://localhost/auth/me', { headers: { - Cookie: `__authdata=test;__iv=ivtest;__sign1=hoge;__sign2=${await sign('test', privkey)};__sign3=${await sign('ivtest', privkey)}`, + Cookie: `token=${token}`, }, }) const res = await handleMe(req, { authName: 'test', privateKey: TEST_PRIVKEY, }) - expect(res.status).toBe(200) - }) + expect(res.status).toBe(401) + }, 10000) + + // その他の部分は userinfo.test.ts でテストする }) diff --git a/package/test/internal/keygen.test.ts b/package/test/internal/keygen.test.ts index 3c3ce2c..d7bfede 100644 --- a/package/test/internal/keygen.test.ts +++ b/package/test/internal/keygen.test.ts @@ -1,20 +1,15 @@ import { describe, expect, it } from 'vitest' import { - decrypt, derivePublicKey, - encrypt, exportKey, generateKeyPair, generateSymmetricKey, importKey, keypairGenAlgorithm, - keypairHashAlgorithm, keypairUsage, - sign, symmetricGenAlgorithm, symmetricUsage, - verify, } from '../../src/internal/keygen' describe('algorithm & usage', () => { @@ -23,11 +18,6 @@ describe('algorithm & usage', () => { expect(keypairGenAlgorithm.namedCurve).toBe('P-521') }) - it('uses the correct keypair hash algorithm', () => { - expect(keypairHashAlgorithm.name).toBe('ECDSA') - expect(keypairHashAlgorithm.hash).toBe('SHA-512') - }) - it('uses the correct symmetric algorithm', () => { expect(symmetricGenAlgorithm.name).toBe('AES-GCM') expect(symmetricGenAlgorithm.length).toBe(256) @@ -112,19 +102,3 @@ describe('generating symmetric key', () => { expect(await exportKey(importedKey)).toBe(exportedKey) }) }) - -describe('using the keys', () => { - it('can sign and verify a message', async () => { - const keypair = await generateKeyPair() - const signature = await sign('Hello, world!', keypair.privateKey) - const verified = await verify('Hello, world!', signature, keypair.publicKey) - expect(verified).toBe(true) - }) - - it('can encrypt and decrypt a message', async () => { - const key = await generateSymmetricKey() - const [encryptedData, iv] = await encrypt('Hello, world!', key) - const decrypted = await decrypt(encryptedData, key, iv) - expect(decrypted).toBe('Hello, world!') - }) -}) diff --git a/package/test/internal/tokengen.test.ts b/package/test/internal/tokengen.test.ts index e44ac3f..7fcfe22 100644 --- a/package/test/internal/tokengen.test.ts +++ b/package/test/internal/tokengen.test.ts @@ -1,334 +1,514 @@ -import dayjs from 'dayjs' -import timezone from 'dayjs/plugin/timezone' -import utc from 'dayjs/plugin/utc' +// jsdom だと TextEncoder と Uint8Array の互換性がないみたいなので node でテストする (挙動は同じ...はず) +// https://github.com/vitest-dev/vitest/issues/4043 +// @vitest-environment node + +import { EncryptJWT } from 'jose' import { describe, expect, it } from 'vitest' -import { generateSymmetricKey, encrypt } from '../../src/internal/keygen' +import { + generateSymmetricKey, + symmetricProtectedHeader, +} from '../../src/internal/keygen' import { generateToken, verifyToken } from '../../src/internal/tokengen' -dayjs.extend(utc) -dayjs.extend(timezone) -dayjs.tz.setDefault('Asia/Tokyo') - -const tokenGenForTest = async ( - name: string[], - pubkey: string[], - callback: string[], - time: number[], - key: CryptoKey, -) => { - const param = new URLSearchParams() - for (const n of name) param.append('user', n) - for (const p of pubkey) param.append('pubkey', p) - for (const c of callback) param.append('callback', c) - for (const t of time) param.append('time', String(t)) - const tokenData = btoa(param.toString()) - return await encrypt(tokenData, key) +const tokenGenForTest = async ({ + subject, + audience, + issuer, + key, + ...payload +}: { + name?: string | string[] + pubkey?: string | string[] + callback?: string | string[] + subject?: string + audience?: string + issuer?: string + key: CryptoKey +}) => { + let res = new EncryptJWT(payload) + if (subject) res = res.setSubject(subject) + if (audience) res = res.setAudience(audience) + if (issuer) res = res.setIssuer(issuer) + return res + .setNotBefore('0 sec') + .setIssuedAt() + .setExpirationTime('10 sec') + .setProtectedHeader(symmetricProtectedHeader) + .encrypt(key) } describe('basic generate & verify', () => { it('generates a token', async () => { const key = await generateSymmetricKey() - const [token, iv] = await generateToken( - 'test', - 'test', - 'http://foo.bar/', - key, - ) + const token = await generateToken({ + name: 'test', + pubkey: 'test', + callback: 'http://foo.bar/', + symkey: key, + }) expect(token).toBeTypeOf('string') - expect(iv).toBeTypeOf('string') }) it('can verify a token', async () => { const key = await generateSymmetricKey() - const [token, iv] = await generateToken( - 'test', - 'test', - 'http://foo.bar/', - key, - ) - const [result, message] = await verifyToken( - 'test', - 'test', - 'http://foo.bar/', - key, - token, - iv, - ) + const token = await generateToken({ + name: 'test', + pubkey: 'test', + callback: 'http://foo.bar/', + symkey: key, + }) + const [result, message] = await verifyToken({ + name: 'test', + pubkey: 'test', + callback: 'http://foo.bar/', + symkey: key, + token: token, + }) expect(result).toBe(true) expect(message).toBe('valid token') }) it('can verify an invalid token', async () => { const key = await generateSymmetricKey() - const [result, message] = await verifyToken( - 'test', - 'test', - 'http://foo.bar/', - key, - 'invalid', - 'invalid', - ) + const [result, message] = await verifyToken({ + name: 'test', + pubkey: 'test', + callback: 'http://foo.bar/', + symkey: key, + token: 'invalid', + }) expect(result).toBe(false) expect(message).toBe('invalid token') }) it('can verify an expired token', async () => { const key = await generateSymmetricKey() - const [token, iv] = await generateToken( - 'test', - 'test', - 'http://foo.bar/', - key, - ) - await new Promise(resolve => setTimeout(resolve, 11000)) - const [result, message] = await verifyToken( - 'test', - 'test', - 'http://foo.bar/', - key, - token, - iv, - ) + const token = await generateToken({ + name: 'test', + pubkey: 'test', + callback: 'http://foo.bar/', + symkey: key, + }) + await new Promise(resolve => setTimeout(resolve, 20000)) + const [result, message] = await verifyToken({ + name: 'test', + pubkey: 'test', + callback: 'http://foo.bar/', + symkey: key, + token: token, + }) expect(result).toBe(false) - expect(message).toBe('token expired') - }, 20000) + expect(message).toBe('invalid token') + }, 25000) }) describe('data verification (user)', () => { it('user lack', async () => { const key = await generateSymmetricKey() - const [token, iv] = await tokenGenForTest( - [], - ['test'], - ['http://foo.bar/'], - [1234567890123], - key, - ) - const [result, message] = await verifyToken( - 'test', - 'test', - 'http://foo.bar/', + const token = await tokenGenForTest({ + name: [], + pubkey: ['test'], + callback: ['http://foo.bar/'], key, - token, - iv, - ) + audience: 'maximum-auth', + issuer: 'maximum-auth', + subject: 'Maximum Auth Token', + }) + const [result, message] = await verifyToken({ + name: 'test', + pubkey: 'test', + callback: 'http://foo.bar/', + symkey: key, + token: token, + }) expect(result).toBe(false) expect(message).toBe('invalid token') }) it('many user', async () => { const key = await generateSymmetricKey() - const [token, iv] = await tokenGenForTest( - ['test1', 'test2'], - ['test'], - ['http://foo.bar/'], - [1234567890123], + const token = await tokenGenForTest({ + name: ['test1', 'test2'], + pubkey: ['test'], + callback: ['http://foo.bar/'], key, - ) - const [result, message] = await verifyToken( - 'test', - 'test', - 'http://foo.bar/', - key, - token, - iv, - ) + audience: 'maximum-auth', + issuer: 'maximum-auth', + subject: 'Maximum Auth Token', + }) + const [result, message] = await verifyToken({ + name: 'test', + pubkey: 'test', + callback: 'http://foo.bar/', + symkey: key, + token: token, + }) expect(result).toBe(false) expect(message).toBe('invalid token') }) - it('user mismatch', async () => { + it('user not provided', async () => { const key = await generateSymmetricKey() - const [token, iv] = await tokenGenForTest( - ['test1'], - ['test'], - ['http://foo.bar/'], - [1234567890123], + const token = await tokenGenForTest({ + name: ['test'], + pubkey: ['test'], + callback: ['http://foo.bar/'], key, - ) - const [result, message] = await verifyToken( - 'test2', - 'test', - 'http://foo.bar/', + audience: 'maximum-auth', + issuer: 'maximum-auth', + subject: 'Maximum Auth Token', + }) + const [result, message] = await verifyToken({ + name: null, + pubkey: 'test', + callback: 'http://foo.bar/', + symkey: key, + token: token, + }) + expect(result).toBe(false) + expect(message).toBe('invalid request') + }) + + it('user mismatch', async () => { + const key = await generateSymmetricKey() + const token = await tokenGenForTest({ + name: 'test1', + pubkey: 'test', + callback: 'http://foo.bar/', key, - token, - iv, - ) + audience: 'maximum-auth', + issuer: 'maximum-auth', + subject: 'Maximum Auth Token', + }) + const [result, message] = await verifyToken({ + name: 'test2', + pubkey: 'test', + callback: 'http://foo.bar/', + symkey: key, + token: token, + }) expect(result).toBe(false) - expect(message).toBe('user mismatch') + expect(message).toBe('invalid token') }) }) describe('data verification (pubkey)', () => { it('pubkey lack', async () => { const key = await generateSymmetricKey() - const [token, iv] = await tokenGenForTest( - ['test'], - [], - ['http://foo.bar/'], - [1234567890123], + const token = await tokenGenForTest({ + name: ['test'], + pubkey: [], + callback: ['http://foo.bar/'], key, - ) - const [result, message] = await verifyToken( - 'test', - 'test', - 'http://foo.bar/', - key, - token, - iv, - ) + audience: 'maximum-auth', + issuer: 'maximum-auth', + subject: 'Maximum Auth Token', + }) + const [result, message] = await verifyToken({ + name: 'test', + pubkey: 'test', + callback: 'http://foo.bar/', + symkey: key, + token: token, + }) expect(result).toBe(false) expect(message).toBe('invalid token') }) it('many pubkey', async () => { const key = await generateSymmetricKey() - const [token, iv] = await tokenGenForTest( - ['test'], - ['test1', 'test2'], - ['http://foo.bar/'], - [1234567890123], - key, - ) - const [result, message] = await verifyToken( - 'test', - 'test', - 'http://foo.bar/', + const token = await tokenGenForTest({ + name: ['test'], + pubkey: ['test1', 'test2'], + callback: ['http://foo.bar/'], key, - token, - iv, - ) + audience: 'maximum-auth', + issuer: 'maximum-auth', + subject: 'Maximum Auth Token', + }) + const [result, message] = await verifyToken({ + name: 'test', + pubkey: 'test', + callback: 'http://foo.bar/', + symkey: key, + token: token, + }) expect(result).toBe(false) expect(message).toBe('invalid token') }) - it('pubkey mismatch', async () => { + it('pubkey not provided', async () => { const key = await generateSymmetricKey() - const [token, iv] = await tokenGenForTest( - ['test'], - ['test1'], - ['http://foo.bar/'], - [1234567890123], + const token = await tokenGenForTest({ + name: 'test', + pubkey: 'test', + callback: 'http://foo.bar/', key, - ) - const [result, message] = await verifyToken( - 'test', - 'test2', - 'http://foo.bar/', + audience: 'maximum-auth', + issuer: 'maximum-auth', + subject: 'Maximum Auth Token', + }) + const [result, message] = await verifyToken({ + name: 'test', + pubkey: null, + callback: 'http://foo.bar/', + symkey: key, + token: token, + }) + expect(result).toBe(false) + expect(message).toBe('invalid request') + }) + + it('pubkey mismatch', async () => { + const key = await generateSymmetricKey() + const token = await tokenGenForTest({ + name: 'test', + pubkey: 'test1', + callback: 'http://foo.bar/', key, - token, - iv, - ) + audience: 'maximum-auth', + issuer: 'maximum-auth', + subject: 'Maximum Auth Token', + }) + const [result, message] = await verifyToken({ + name: 'test', + pubkey: 'test2', + callback: 'http://foo.bar/', + symkey: key, + token: token, + }) expect(result).toBe(false) - expect(message).toBe('pubkey mismatch') + expect(message).toBe('invalid token') }) }) describe('data verification (callback)', () => { it('callback lack', async () => { const key = await generateSymmetricKey() - const [token, iv] = await tokenGenForTest( - ['test'], - ['test'], - [], - [1234567890123], - key, - ) - const [result, message] = await verifyToken( - 'test', - 'test', - 'http://foo.bar/', + const token = await tokenGenForTest({ + name: ['test'], + pubkey: ['test'], + callback: [], key, - token, - iv, - ) + audience: 'maximum-auth', + issuer: 'maximum-auth', + subject: 'Maximum Auth Token', + }) + const [result, message] = await verifyToken({ + name: 'test', + pubkey: 'test', + callback: 'http://foo.bar/', + symkey: key, + token: token, + }) expect(result).toBe(false) expect(message).toBe('invalid token') }) it('many callback', async () => { const key = await generateSymmetricKey() - const [token, iv] = await tokenGenForTest( - ['test'], - ['test'], - ['http://foo.bar/', 'http://foo.baz/'], - [1234567890123], + const token = await tokenGenForTest({ + name: ['test'], + pubkey: ['test'], + callback: ['http://foo.bar/', 'http://foo.baz/'], key, - ) - const [result, message] = await verifyToken( - 'test', - 'test', - 'http://foo.bar/', - key, - token, - iv, - ) + audience: 'maximum-auth', + issuer: 'maximum-auth', + subject: 'Maximum Auth Token', + }) + const [result, message] = await verifyToken({ + name: 'test', + pubkey: 'test', + callback: 'http://foo.bar/', + symkey: key, + token: token, + }) expect(result).toBe(false) expect(message).toBe('invalid token') }) + it('callback not provided', async () => { + const key = await generateSymmetricKey() + const token = await tokenGenForTest({ + name: 'test', + pubkey: 'test', + callback: 'http://foo.baz/', + key, + audience: 'maximum-auth', + issuer: 'maximum-auth', + subject: 'Maximum Auth Token', + }) + const [result, message] = await verifyToken({ + name: 'test', + pubkey: 'test', + callback: null, + symkey: key, + token: token, + }) + expect(result).toBe(false) + expect(message).toBe('invalid request') + }) + it('callback mismatch', async () => { const key = await generateSymmetricKey() - const [token, iv] = await tokenGenForTest( - ['test'], - ['test'], - ['http://foo.bar/'], - [1234567890123], + const token = await tokenGenForTest({ + name: 'test', + pubkey: 'test', + callback: 'http://foo.bar/', key, - ) - const [result, message] = await verifyToken( - 'test', - 'test', - 'http://foo.baz/', + audience: 'maximum-auth', + issuer: 'maximum-auth', + subject: 'Maximum Auth Token', + }) + const [result, message] = await verifyToken({ + name: 'test', + pubkey: 'test', + callback: 'http://foo.baz/', + symkey: key, + token: token, + }) + expect(result).toBe(false) + expect(message).toBe('invalid token') + }) +}) + +describe('data verification (subject)', () => { + const payload = { + name: 'test', + pubkey: 'test', + callback: 'http://foo.bar/', + } + + it('subject lack', async () => { + const key = await generateSymmetricKey() + const token = await tokenGenForTest({ + ...payload, + issuer: 'maximum-auth', + audience: 'maximum-auth', + key, + }) + const [result, message] = await verifyToken({ + name: 'test', + pubkey: 'test', + callback: 'http://foo.bar/', + symkey: key, + token: token, + }) + expect(result).toBe(false) + expect(message).toBe('invalid token') + }) + + it('subject mismatch', async () => { + const key = await generateSymmetricKey() + const token = await tokenGenForTest({ + ...payload, + subject: 'test', + issuer: 'maximum-auth', + audience: 'maximum-auth', key, - token, - iv, - ) + }) + const [result, message] = await verifyToken({ + name: 'test', + pubkey: 'test', + callback: 'http://foo.bar/', + symkey: key, + token: token, + }) expect(result).toBe(false) - expect(message).toBe('callback mismatch') + expect(message).toBe('invalid token') }) }) -describe('data verification (time)', () => { - it('time lack', async () => { +describe('data verification (audience)', () => { + const payload = { + name: 'test', + pubkey: 'test', + callback: 'http://foo.bar/', + } + + it('audience lack', async () => { const key = await generateSymmetricKey() - const [token, iv] = await tokenGenForTest( - ['test'], - ['test'], - ['http://foo.bar/'], - [], + const token = await tokenGenForTest({ + ...payload, + subject: 'Maximum Auth Token', + issuer: 'maximum-auth', key, - ) - const [result, message] = await verifyToken( - 'test', - 'test', - 'http://foo.bar/', + }) + const [result, message] = await verifyToken({ + name: 'test', + pubkey: 'test', + callback: 'http://foo.bar/', + symkey: key, + token: token, + }) + expect(result).toBe(false) + expect(message).toBe('invalid token') + }) + + it('audience mismatch', async () => { + const key = await generateSymmetricKey() + const token = await tokenGenForTest({ + ...payload, + subject: 'Maximum Auth Token', + issuer: 'maximum-auth', + audience: 'test', key, - token, - iv, - ) + }) + const [result, message] = await verifyToken({ + name: 'test', + pubkey: 'test', + callback: 'http://foo.bar/', + symkey: key, + token: token, + }) expect(result).toBe(false) expect(message).toBe('invalid token') }) +}) + +describe('data verification (issuer)', () => { + const payload = { + name: 'test', + pubkey: 'test', + callback: 'http://foo.bar/', + } - it('many time', async () => { + it('issuer lack', async () => { const key = await generateSymmetricKey() - const [token, iv] = await tokenGenForTest( - ['test'], - ['test'], - ['http://foo.bar/'], - [1234567890123, 1234567890124], + const token = await tokenGenForTest({ + ...payload, + subject: 'Maximum Auth Token', + audience: 'maximum-auth', key, - ) - const [result, message] = await verifyToken( - 'test', - 'test', - 'http://foo.bar/', + }) + const [result, message] = await verifyToken({ + name: 'test', + pubkey: 'test', + callback: 'http://foo.bar/', + symkey: key, + token: token, + }) + expect(result).toBe(false) + expect(message).toBe('invalid token') + }) + + it('issuer mismatch', async () => { + const key = await generateSymmetricKey() + const token = await tokenGenForTest({ + ...payload, + subject: 'Maximum Auth Token', + audience: 'maximum-auth', + issuer: 'test', key, - token, - iv, - ) + }) + const [result, message] = await verifyToken({ + name: 'test', + pubkey: 'test', + callback: 'http://foo.bar/', + symkey: key, + token: token, + }) expect(result).toBe(false) expect(message).toBe('invalid token') }) diff --git a/package/test/middleware.test.ts b/package/test/middleware.test.ts index 2a256d6..f8a7cc9 100644 --- a/package/test/middleware.test.ts +++ b/package/test/middleware.test.ts @@ -1,18 +1,16 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion */ -import dayjs from 'dayjs' -import timezone from 'dayjs/plugin/timezone' -import utc from 'dayjs/plugin/utc' +// jsdom だと TextEncoder と Uint8Array の互換性がないみたいなので node でテストする (挙動は同じ...はず) +// https://github.com/vitest-dev/vitest/issues/4043 +// @vitest-environment node + +import { SignJWT } from 'jose' import type { MockInstance } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { importKey, sign } from '../src/internal' +import { importKey, keypairProtectedHeader } from '../src/internal' import { middleware } from '../src/middleware' -dayjs.extend(utc) -dayjs.extend(timezone) -dayjs.tz.setDefault('Asia/Tokyo') - const TEST_PRIVKEY = 'eyJrdHkiOiJFQyIsImtleV9vcHMiOlsic2lnbiJdLCJleHQiOnRydWUsImNydiI6IlAtNTIxIiwieCI6IkFmRVhoOTY5VTRxMUo3RDNZQlBpMFY2ODlPdUFaOVJGZS1STHRhSFE4QmUwTEQ2LWQ5dlJ1ZEFFMnlHTE10Z0lMX1drekhSRF9TNjg2M1BkUUZKLTJydnEiLCJ5IjoiQUtpZ0Ftd2FPcF9Vd3V2NXZqOEE4UXFjS2Z3WG42enZNUHU3QWhOdkFDdE5qdC1UdTBvRWg3d0Z5dGJDR3FNaFRYMm01SEJnZlZhbTh5aTRMWkRiSWlYcCIsImQiOiJBVVJnWGpLazBtaDlsbDdtVDZvRDJTT09TZEF1bWpITFQtOU1pZnRsd1VNOGpiTlNRMkJxOVlBemNvVkZRRmV5VzEzbVNzY3dUT0dUbTRxZ1QwWDJnV1VmIn0=' @@ -181,63 +179,12 @@ describe('prod mode', () => { expect(data.name).toBeTypeOf('string') expect(data.pubkey).toBeTypeOf('string') expect(data.callback).toBeTypeOf('string') - return new Response( - JSON.stringify({ token: btoa('token'), iv: btoa('iv') }), - { - status: 200, - headers: { - 'Content-Type': 'application/json', - }, + return new Response('DUMMY_TOKEN', { + status: 200, + headers: { + 'Content-Type': 'text/plain', }, - ) - } - - // webapp/app/routes/user.tsx - if (path === 'https://auth.server.test/user') { - expect(options).toBeTruthy() - expect(options!.method).toBe('POST') - expect(options!.headers).toBeTruthy() - const headers = new Headers(options!.headers) - expect(headers.get('content-type')).toBeTruthy() - expect( - headers.get('content-type')!.includes('application/json'), - ).toBeTruthy() - expect(options!.body).toBeTruthy() - await expect( - new Promise((resolve, reject) => { - try { - JSON.parse(options!.body as string) - resolve(true) - } catch (e) { - reject(e) - } - }), - ).resolves.toBeTruthy() - const data = JSON.parse(options!.body as string) - expect(data.name).toBeTypeOf('string') - expect(data.pubkey).toBeTypeOf('string') - expect(data.data).toBeTypeOf('string') - expect(data.iv).toBeTypeOf('string') - expect(data.sgn1).toBeTypeOf('string') - expect(data.sgn2).toBeTypeOf('string') - expect(data.sgn3).toBeTypeOf('string') - return new Response( - JSON.stringify({ - id: '120705481', - display_name: 'saitamau-maximum', - is_member: true, - profile_image: - 'https://avatars.githubusercontent.com/u/120705481?v=4', - teams: ['leaders'], - time: dayjs.tz().valueOf(), - }), - { - status: 200, - headers: { - 'Content-Type': 'application/json', - }, - }, - ) + }) } console.error('unexpected fetch', path) @@ -280,6 +227,17 @@ describe('prod mode', () => { it('executes next() when logged in', async () => { const privkey = await importKey(TEST_PRIVKEY, 'privateKey') + const payload = { hoge: 'fuga' } + const token = await new SignJWT(payload) + .setSubject('Maximum Auth Data') + .setAudience('test') + .setIssuer('test') + .setNotBefore('0 sec') + .setIssuedAt() + .setExpirationTime('1 sec') + .setProtectedHeader(keypairProtectedHeader) + .sign(privkey) + const res = await middleware({ env: { AUTH_NAME: 'test', @@ -289,7 +247,7 @@ describe('prod mode', () => { request: { url: 'http://localhost/', headers: new Headers({ - cookie: `__authdata=test;__iv=ivtest;__sign1=hoge;__sign2=${await sign('test', privkey)};__sign3=${await sign('ivtest', privkey)}`, + cookie: `token=${token}`, }), }, next: async () => { diff --git a/package/test/userinfo.test.ts b/package/test/userinfo.test.ts index b563d1f..977b3cf 100644 --- a/package/test/userinfo.test.ts +++ b/package/test/userinfo.test.ts @@ -1,16 +1,14 @@ -/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */ -import dayjs from 'dayjs' -import timezone from 'dayjs/plugin/timezone' -import utc from 'dayjs/plugin/utc' -import type { MockInstance } from 'vitest' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +/* eslint-disable @typescript-eslint/no-explicit-any */ -import { importKey, sign } from '../src/internal/keygen' -import { checkLoggedIn, getUserInfo } from '../src/userinfo' +// jsdom だと TextEncoder と Uint8Array の互換性がないみたいなので node でテストする (挙動は同じ...はず) +// https://github.com/vitest-dev/vitest/issues/4043 +// @vitest-environment node -dayjs.extend(utc) -dayjs.extend(timezone) -dayjs.tz.setDefault('Asia/Tokyo') +import { SignJWT } from 'jose' +import { describe, expect, it } from 'vitest' + +import { importKey, keypairProtectedHeader } from '../src/internal/keygen' +import { getUserInfo } from '../src/userinfo' const TEST_PRIVKEY = 'eyJrdHkiOiJFQyIsImtleV9vcHMiOlsic2lnbiJdLCJleHQiOnRydWUsImNydiI6IlAtNTIxIiwieCI6IkFmRVhoOTY5VTRxMUo3RDNZQlBpMFY2ODlPdUFaOVJGZS1STHRhSFE4QmUwTEQ2LWQ5dlJ1ZEFFMnlHTE10Z0lMX1drekhSRF9TNjg2M1BkUUZKLTJydnEiLCJ5IjoiQUtpZ0Ftd2FPcF9Vd3V2NXZqOEE4UXFjS2Z3WG42enZNUHU3QWhOdkFDdE5qdC1UdTBvRWg3d0Z5dGJDR3FNaFRYMm01SEJnZlZhbTh5aTRMWkRiSWlYcCIsImQiOiJBVVJnWGpLazBtaDlsbDdtVDZvRDJTT09TZEF1bWpITFQtOU1pZnRsd1VNOGpiTlNRMkJxOVlBemNvVkZRRmV5VzEzbVNzY3dUT0dUbTRxZ1QwWDJnV1VmIn0=' @@ -29,13 +27,6 @@ describe('required options missing', () => { 'options.privateKey は必須です', ) }) - - it('throws when pubkey is not provided (checkLoggedIn)', async () => { - const req = new Request('https://example.com', { method: 'GET' }) - await expect(checkLoggedIn(req, null)).rejects.toThrow( - 'publicKey が null です', - ) - }) }) describe('dev mode', () => { @@ -69,106 +60,10 @@ describe('dev mode', () => { 'https://avatars.githubusercontent.com/u/120705481?v=4', ) expect(res).toHaveProperty('teams', ['leaders']) - expect(res).toHaveProperty('time') - // 1ms 以内の誤差を許容 - expect(dayjs.tz().valueOf() - res!.time).toBeLessThan(1000) }) }) describe('prod mode', () => { - let mockedFetch: MockInstance< - [input: string | Request | URL, init?: RequestInit | undefined], - Promise - > - - beforeEach(() => { - mockedFetch = vi - .spyOn(global, 'fetch') - .mockImplementation(async (path, options) => { - // webapp/app/routes/user.tsx - if ( - path === 'https://auth.server.test/user' || - path === 'https://auth.maximum.vc/user' - ) { - expect(options).toBeTruthy() - expect(options!.method).toBe('POST') - expect(options!.headers).toBeTruthy() - const headers = new Headers(options!.headers) - expect(headers.get('content-type')).toBeTruthy() - expect( - headers.get('content-type')!.includes('application/json'), - ).toBeTruthy() - expect(options!.body).toBeTruthy() - await expect( - new Promise((resolve, reject) => { - try { - JSON.parse(options!.body as string) - resolve(true) - } catch (e) { - reject(e) - } - }), - ).resolves.toBeTruthy() - const data = JSON.parse(options!.body as string) - expect(data.name).toBeTypeOf('string') - expect(data.pubkey).toBeTypeOf('string') - expect(data.data).toBeTypeOf('string') - expect(data.iv).toBeTypeOf('string') - expect(data.sgn1).toBeTypeOf('string') - expect(data.sgn2).toBeTypeOf('string') - expect(data.sgn3).toBeTypeOf('string') - return new Response( - JSON.stringify({ - id: '120705481', - display_name: 'saitamau-maximum', - is_member: true, - profile_image: - 'https://avatars.githubusercontent.com/u/120705481?v=4', - teams: ['leaders'], - time: dayjs.tz().valueOf(), - }), - { - status: 200, - headers: { - 'Content-Type': 'application/json', - }, - }, - ) - } - - if (path === 'https://auth.server.test2/user') { - return new Response( - JSON.stringify({ - id: '120705481', - display_name: 'saitamau-maximum', - is_member: false, - profile_image: - 'https://avatars.githubusercontent.com/u/120705481?v=4', - teams: ['leaders'], - time: dayjs.tz().valueOf(), - }), - { - status: 200, - headers: { - 'Content-Type': 'application/json', - }, - }, - ) - } - - if (path === 'https://auth.server.test.dummy/user') { - return new Response('Server down', { status: 503 }) - } - - console.error('unexpected fetch', path) - return new Response(null, { status: 500 }) - }) - }) - - afterEach(() => { - mockedFetch.mockRestore() - }) - it('returns null when not logged in', async () => { const req = new Request('http://localhost/auth/me') const res = await getUserInfo(req, { @@ -193,10 +88,20 @@ describe('prod mode', () => { it('returns data when logged in', async () => { const privkey = await importKey(TEST_PRIVKEY, 'privateKey') + const payload = { hoge: 'fuga' } + const token = await new SignJWT(payload) + .setSubject('Maximum Auth Data') + .setAudience('test') + .setIssuer('test') + .setNotBefore('0 sec') + .setIssuedAt() + .setExpirationTime('1 sec') + .setProtectedHeader(keypairProtectedHeader) + .sign(privkey) const req = new Request('http://localhost/auth/me', { headers: { - Cookie: `__authdata=test;__iv=ivtest;__sign1=hoge;__sign2=${await sign('test', privkey)};__sign3=${await sign('ivtest', privkey)}`, + Cookie: `token=${token}`, }, }) const res = await getUserInfo(req, { @@ -205,74 +110,6 @@ describe('prod mode', () => { authOrigin: 'https://auth.server.test', }) expect(res).not.toBe(null) - expect(res).toHaveProperty('id', '120705481') - expect(res).toHaveProperty('display_name', 'saitamau-maximum') - expect(res).toHaveProperty('is_member', true) - expect(res).toHaveProperty( - 'profile_image', - 'https://avatars.githubusercontent.com/u/120705481?v=4', - ) - expect(res).toHaveProperty('teams', ['leaders']) - expect(res).toHaveProperty('time') - // 1ms 以内の誤差を許容 - expect(dayjs.tz().valueOf() - res!.time).toBeLessThan(1000) - }) - - it('returns null when logged in as non-member', async () => { - const privkey = await importKey(TEST_PRIVKEY, 'privateKey') - - const req = new Request('http://localhost/auth/me', { - headers: { - Cookie: `__authdata=test;__iv=ivtest;__sign1=hoge;__sign2=${await sign('test', privkey)};__sign3=${await sign('ivtest', privkey)}`, - }, - }) - const res = await getUserInfo(req, { - authName: 'test', - privateKey: TEST_PRIVKEY, - authOrigin: 'https://auth.server.test2', - }) - expect(res).toBe(null) - }) - - it('returns null when fetch failed', async () => { - const privkey = await importKey(TEST_PRIVKEY, 'privateKey') - - const req = new Request('http://localhost/auth/me', { - headers: { - Cookie: `__authdata=test;__iv=ivtest;__sign1=hoge;__sign2=${await sign('test', privkey)};__sign3=${await sign('ivtest', privkey)}`, - }, - }) - const res = await getUserInfo(req, { - authName: 'test', - privateKey: TEST_PRIVKEY, - authOrigin: 'https://auth.server.test.dummy', - }) - expect(res).toBe(null) - }) - - it('works if authOrigin is missing', async () => { - const privkey = await importKey(TEST_PRIVKEY, 'privateKey') - - const req = new Request('http://localhost/auth/me', { - headers: { - Cookie: `__authdata=test;__iv=ivtest;__sign1=hoge;__sign2=${await sign('test', privkey)};__sign3=${await sign('ivtest', privkey)}`, - }, - }) - const res = await getUserInfo(req, { - authName: 'test', - privateKey: TEST_PRIVKEY, - }) - expect(res).not.toBe(null) - expect(res).toHaveProperty('id', '120705481') - expect(res).toHaveProperty('display_name', 'saitamau-maximum') - expect(res).toHaveProperty('is_member', true) - expect(res).toHaveProperty( - 'profile_image', - 'https://avatars.githubusercontent.com/u/120705481?v=4', - ) - expect(res).toHaveProperty('teams', ['leaders']) - expect(res).toHaveProperty('time') - // 1ms 以内の誤差を許容 - expect(dayjs.tz().valueOf() - res!.time).toBeLessThan(1000) + expect(res).toHaveProperty('hoge', 'fuga') }) }) diff --git a/package/test/validate.test.ts b/package/test/validate.test.ts index 7dd0887..abf55fb 100644 --- a/package/test/validate.test.ts +++ b/package/test/validate.test.ts @@ -1,15 +1,13 @@ -import dayjs from 'dayjs' -import timezone from 'dayjs/plugin/timezone' -import utc from 'dayjs/plugin/utc' +// jsdom だと TextEncoder と Uint8Array の互換性がないみたいなので node でテストする (挙動は同じ...はず) +// https://github.com/vitest-dev/vitest/issues/4043 +// @vitest-environment node + +import { SignJWT } from 'jose' import { expect, it, vi } from 'vitest' -import { importKey, sign } from '../src/internal' +import { importKey, keypairProtectedHeader } from '../src/internal' import { validateRequest } from '../src/validate' -dayjs.extend(utc) -dayjs.extend(timezone) -dayjs.tz.setDefault('Asia/Tokyo') - vi.mock('../src/internal', async importOriginal => { const mod = await importOriginal() return { @@ -28,33 +26,34 @@ const TEST_PUBKEY = const TEST_PRIVKEY = 'eyJrdHkiOiJFQyIsImtleV9vcHMiOlsic2lnbiJdLCJleHQiOnRydWUsImNydiI6IlAtNTIxIiwieCI6IkFiaUVFeUExRk1YZkZJVjdKeHNIVFcyZUFzU19ZZWdIb0JadEdsWmhkX0l2VUsyZmstekQteHRIZmFBREpxcjV1YkxMNnliLU1LS2R5LTVIb0Z1UjRDdXUiLCJ5IjoiQWV0Yi1IQ2NfSGxoUnFGMU9KR1JpWDEteWlpOUlIc2sxSU9udnVxejl5T1RZbDhqUi1zUzR6RGlHejgwSVhydkJtc081T0R2aWFsWDdROXpyeTBxZUtJZCIsImQiOiJBRjgtRHJSbmFabjhkRVppV2ozR2owY3F3VWlWNFp3NmhyX2EyT1FfbzMxQmVVNUc3RXhpQmV1dzcyemx5RVlCMGpXTWZKYUZzeUVhdU50UmFKY045cFJNIn0=' +const generateToken = async ({ + subject = 'Maximum Auth Proxy', + issuer = 'maximum-auth-proxy', + exp = '5 sec', + privateKey, +}: { + subject?: string + issuer?: string + exp?: string + privateKey: CryptoKey +}) => { + let jwt = new SignJWT({}) + .setNotBefore('0 sec') + .setIssuedAt() + .setProtectedHeader(keypairProtectedHeader) + if (subject) jwt = jwt.setSubject(subject) + if (issuer) jwt = jwt.setIssuer(issuer) + if (exp) jwt = jwt.setExpirationTime(exp) + return await jwt.sign(privateKey) +} + it("returns false if 'X-Maximum-Auth-Pubkey' is missing", async () => { - const time = dayjs.tz().valueOf().toString() - const rand = btoa(crypto.getRandomValues(new Uint8Array(16)).toString()) - const privkey = await importKey(TEST_PRIVKEY, 'privateKey') + const privateKey = await importKey(TEST_PRIVKEY, 'privateKey') + const token = await generateToken({ privateKey }) const header = new Headers() // header.set('X-Maximum-Auth-Pubkey',TEST_PUBKEY) - header.set('X-Maximum-Auth-Time', time) - header.set('X-Maximum-Auth-Key', rand) - header.set('X-Maximum-Auth-Mac', await sign(`${time}___${rand}`, privkey)) - - const res = await validateRequest(header, { - proxyPubkey: TEST_PUBKEY, - }) - expect(res).toBe(false) -}) - -it("returns false if 'X-Maximum-Auth-Time' is missing", async () => { - const time = dayjs.tz().valueOf().toString() - const rand = btoa(crypto.getRandomValues(new Uint8Array(16)).toString()) - const privkey = await importKey(TEST_PRIVKEY, 'privateKey') - - const header = new Headers() - header.set('X-Maximum-Auth-Pubkey', TEST_PUBKEY) - // header.set('X-Maximum-Auth-Time', time) - header.set('X-Maximum-Auth-Key', rand) - header.set('X-Maximum-Auth-Mac', await sign(`${time}___${rand}`, privkey)) + header.set('X-Maximum-Auth-Token', token) const res = await validateRequest(header, { proxyPubkey: TEST_PUBKEY, @@ -62,33 +61,13 @@ it("returns false if 'X-Maximum-Auth-Time' is missing", async () => { expect(res).toBe(false) }) -it("returns false if 'X-Maximum-Auth-Key' is missing", async () => { - const time = dayjs.tz().valueOf().toString() - const rand = btoa(crypto.getRandomValues(new Uint8Array(16)).toString()) - const privkey = await importKey(TEST_PRIVKEY, 'privateKey') +it("returns false if 'X-Maximum-Auth-Token' is missing", async () => { + // const privateKey = await importKey(TEST_PRIVKEY, 'privateKey') + // const token = await generateToken({ privateKey}) const header = new Headers() header.set('X-Maximum-Auth-Pubkey', TEST_PUBKEY) - header.set('X-Maximum-Auth-Time', time) - // header.set('X-Maximum-Auth-Key', rand) - header.set('X-Maximum-Auth-Mac', await sign(`${time}___${rand}`, privkey)) - - const res = await validateRequest(header, { - proxyPubkey: TEST_PUBKEY, - }) - expect(res).toBe(false) -}) - -it("returns false if 'X-Maximum-Auth-Mac' is missing", async () => { - const time = dayjs.tz().valueOf().toString() - const rand = btoa(crypto.getRandomValues(new Uint8Array(16)).toString()) - // const privkey = await importKey(TEST_PRIVKEY, 'privateKey') - - const header = new Headers() - header.set('X-Maximum-Auth-Pubkey', TEST_PUBKEY) - header.set('X-Maximum-Auth-Time', time) - header.set('X-Maximum-Auth-Key', rand) - // header.set('X-Maximum-Auth-Mac', await sign(`${time}___${rand}`, privkey)) + // header.set('X-Maximum-Auth-Token', token) const res = await validateRequest(header, { proxyPubkey: TEST_PUBKEY, @@ -97,15 +76,12 @@ it("returns false if 'X-Maximum-Auth-Mac' is missing", async () => { }) it("returns false if 'X-Maximum-Auth-Pubkey' doesn't match proxyPubkey", async () => { - const time = dayjs.tz().valueOf().toString() - const rand = btoa(crypto.getRandomValues(new Uint8Array(16)).toString()) - const privkey = await importKey(TEST_PRIVKEY, 'privateKey') + const privateKey = await importKey(TEST_PRIVKEY, 'privateKey') + const token = await generateToken({ privateKey }) const header = new Headers() header.set('X-Maximum-Auth-Pubkey', TEST_PUBKEY) - header.set('X-Maximum-Auth-Time', time) - header.set('X-Maximum-Auth-Key', rand) - header.set('X-Maximum-Auth-Mac', await sign(`${time}___${rand}`, privkey)) + header.set('X-Maximum-Auth-Token', token) const res = await validateRequest(header, { proxyPubkey: TEST_PUBKEY + 'hoge', @@ -113,16 +89,13 @@ it("returns false if 'X-Maximum-Auth-Pubkey' doesn't match proxyPubkey", async ( expect(res).toBe(false) }) -it('returns false if more than 15 secs have passed', async () => { - const time = dayjs.tz().add(-16, 'second').valueOf().toString() - const rand = btoa(crypto.getRandomValues(new Uint8Array(16)).toString()) - const privkey = await importKey(TEST_PRIVKEY, 'privateKey') +it("returns false if issuer doesn't match", async () => { + const privateKey = await importKey(TEST_PRIVKEY, 'privateKey') + const token = await generateToken({ privateKey, issuer: 'test' }) const header = new Headers() header.set('X-Maximum-Auth-Pubkey', TEST_PUBKEY) - header.set('X-Maximum-Auth-Time', time) - header.set('X-Maximum-Auth-Key', rand) - header.set('X-Maximum-Auth-Mac', await sign(`${time}___${rand}`, privkey)) + header.set('X-Maximum-Auth-Token', token) const res = await validateRequest(header, { proxyPubkey: TEST_PUBKEY, @@ -130,19 +103,13 @@ it('returns false if more than 15 secs have passed', async () => { expect(res).toBe(false) }) -it("returns false if the MAC doesn't match", async () => { - const time = dayjs.tz().valueOf().toString() - const rand = btoa(crypto.getRandomValues(new Uint8Array(16)).toString()) - const privkey = await importKey(TEST_PRIVKEY, 'privateKey') +it("returns false if subject doesn't match", async () => { + const privateKey = await importKey(TEST_PRIVKEY, 'privateKey') + const token = await generateToken({ privateKey, subject: 'test' }) const header = new Headers() header.set('X-Maximum-Auth-Pubkey', TEST_PUBKEY) - header.set('X-Maximum-Auth-Time', time) - header.set('X-Maximum-Auth-Key', rand) - header.set( - 'X-Maximum-Auth-Mac', - await sign(`${time}___${rand}_hoge`, privkey), - ) + header.set('X-Maximum-Auth-Token', token) const res = await validateRequest(header, { proxyPubkey: TEST_PUBKEY, @@ -150,35 +117,32 @@ it("returns false if the MAC doesn't match", async () => { expect(res).toBe(false) }) -it('returns true if the all conditions match', async () => { - const time = dayjs.tz().valueOf().toString() - const rand = btoa(crypto.getRandomValues(new Uint8Array(16)).toString()) - const privkey = await importKey(TEST_PRIVKEY, 'privateKey') +it('returns false if token expired', async () => { + const privateKey = await importKey(TEST_PRIVKEY, 'privateKey') + const token = await generateToken({ privateKey, exp: '1 sec' }) const header = new Headers() header.set('X-Maximum-Auth-Pubkey', TEST_PUBKEY) - header.set('X-Maximum-Auth-Time', time) - header.set('X-Maximum-Auth-Key', rand) - header.set('X-Maximum-Auth-Mac', await sign(`${time}___${rand}`, privkey)) + header.set('X-Maximum-Auth-Token', token) + + // tolerance 5 sec + 1 sec -> 余裕もって 7 秒待つ + await new Promise(resolve => setTimeout(resolve, 7000)) const res = await validateRequest(header, { proxyPubkey: TEST_PUBKEY, }) - expect(res).toBe(true) -}) + expect(res).toBe(false) +}, 10000) it('fallbacks to PROXY_PUBKEY if proxyPubkey is not provided', async () => { - const time = dayjs.tz().valueOf().toString() - const rand = btoa(crypto.getRandomValues(new Uint8Array(16)).toString()) - const privkey = await importKey(TEST_PROXYPRIVKEY, 'privateKey') + const privateKey = await importKey(TEST_PROXYPRIVKEY, 'privateKey') + const token = await generateToken({ privateKey }) const pubkey = 'eyJrdHkiOiJFQyIsImtleV9vcHMiOlsidmVyaWZ5Il0sImV4dCI6dHJ1ZSwiY3J2IjoiUC01MjEiLCJ4IjoiQVB0MUFSd253eU82WGZEcWFNNFU2SGRRSnlDTUdnUk5wQkwxSXdjdmRfdVRNc3NqMmRCT3VDakFKQ1BRc2VFdVl5blZXdXN6Yi1UM2REQ29ROTlyeVo2NSIsInkiOiJBRjZHd19weTRrZ0xzRUQ2bHlWOVlEd0tTZm1saHQtUHFqSjZ2cXZxZlNmRnhHVG9VR3ZGOHk0OVByclowS3dlM213MFB1cFRHQ0dpLXl5UFpCd1pGenRJIn0=' const header = new Headers() header.set('X-Maximum-Auth-Pubkey', pubkey) - header.set('X-Maximum-Auth-Time', time) - header.set('X-Maximum-Auth-Key', rand) - header.set('X-Maximum-Auth-Mac', await sign(`${time}___${rand}`, privkey)) + header.set('X-Maximum-Auth-Token', token) const res = await validateRequest(header) expect(res).toBe(true) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 56f52e9..7f62be3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,9 +44,9 @@ importers: cookie: specifier: ^0.6.0 version: 0.6.0 - dayjs: - specifier: ^1.11.13 - version: 1.11.13 + jose: + specifier: ^5.9.3 + version: 5.9.3 devDependencies: '@cloudflare/workers-types': specifier: ^4.20240925.0 @@ -93,12 +93,12 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 - dayjs: - specifier: ^1.11.13 - version: 1.11.13 isbot: specifier: ^5.1.17 version: 5.1.17 + jose: + specifier: ^5.9.3 + version: 5.9.3 octokit: specifier: ^4.0.2 version: 4.0.2 @@ -169,9 +169,9 @@ importers: cookie: specifier: ^0.6.0 version: 0.6.0 - dayjs: - specifier: ^1.11.13 - version: 1.11.13 + jose: + specifier: ^5.9.3 + version: 5.9.3 devDependencies: '@cloudflare/workers-types': specifier: ^4.20240925.0 @@ -1882,9 +1882,6 @@ packages: resolution: {integrity: sha512-t/Ygsytq+R995EJ5PZlD4Cu56sWa8InXySaViRzw9apusqsOO2bQP+SbYzAhR0pFKoB+43lYy8rWban9JSuXnA==} engines: {node: '>= 0.4'} - dayjs@1.11.13: - resolution: {integrity: sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==} - debug@2.6.9: resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} peerDependencies: @@ -2859,6 +2856,9 @@ packages: javascript-stringify@2.1.0: resolution: {integrity: sha512-JVAfqNPTvNq3sB/VHQJAFxN/sPgKnsKrCwyRt15zwNCdrMMJDdcEOdubuy+DuJYYdm0ox1J4uzEuYKkN+9yhVg==} + jose@5.9.3: + resolution: {integrity: sha512-egLIoYSpcd+QUF+UHgobt5YzI2Pkw/H39ou9suW687MY6PmCwPmkNV/4TNjn1p2tX5xO3j0d0sq5hiYE24bSlg==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -5839,7 +5839,7 @@ snapshots: '@babel/plugin-syntax-typescript': 7.24.1(@babel/core@7.24.3) '@vanilla-extract/babel-plugin-debug-ids': 1.0.5 '@vanilla-extract/css': 1.14.1 - esbuild: 0.17.6 + esbuild: 0.17.19 eval: 0.1.8 find-up: 5.0.0 javascript-stringify: 2.1.0 @@ -6440,8 +6440,6 @@ snapshots: es-errors: 1.3.0 is-data-view: 1.0.1 - dayjs@1.11.13: {} - debug@2.6.9: dependencies: ms: 2.0.0 @@ -7702,6 +7700,8 @@ snapshots: javascript-stringify@2.1.0: {} + jose@5.9.3: {} + js-tokens@4.0.0: {} js-tokens@9.0.0: {} diff --git a/webapp/app/routes/continue/route.tsx b/webapp/app/routes/continue/route.tsx index a436cfc..eb0ad38 100644 --- a/webapp/app/routes/continue/route.tsx +++ b/webapp/app/routes/continue/route.tsx @@ -2,21 +2,18 @@ import type { ActionFunction, - LoaderFunction, + LoaderFunctionArgs, MetaFunction, } from '@remix-run/cloudflare' import { json } from '@remix-run/cloudflare' import { useLoaderData } from '@remix-run/react' -import { encrypt, importKey, sign } from '@saitamau-maximum/auth/internal' +import { + importKey, + keypairProtectedHeader, +} from '@saitamau-maximum/auth/internal' import clsx from 'clsx' -import dayjs from 'dayjs' -import timezone from 'dayjs/plugin/timezone' -import utc from 'dayjs/plugin/utc' - -dayjs.extend(utc) -dayjs.extend(timezone) -dayjs.tz.setDefault('Asia/Tokyo') +import { SignJWT } from 'jose' import pubkeyData from '../../../data/pubkey.json' import cookieSessionStorage from '../../../utils/session.server' @@ -26,13 +23,13 @@ import style from './style.module.css' export const action: ActionFunction = () => new Response('method not allowed', { status: 405 }) -export const loader: LoaderFunction = async ({ context, request }) => { +export const loader = async ({ context, request }: LoaderFunctionArgs) => { const envvar = context.cloudflare.env const { getSession } = cookieSessionStorage(envvar) const session = await getSession(request.headers.get('Cookie')) - const cburl = session.get('continue_to') + const cburl = session.get('continue_to')! const cbname = decodeURIComponent(session.get('continue_name')!) const registeredData = pubkeyData.find(data => data.name === cbname) @@ -41,22 +38,25 @@ export const loader: LoaderFunction = async ({ context, request }) => { throw new Response('invalid request', { status: 400 }) } - const symkey = await importKey(context.cloudflare.env.SYMKEY, 'symmetric') const privkey = await importKey(context.cloudflare.env.PRIVKEY, 'privateKey') - const [authdata, iv] = await encrypt( - JSON.stringify({ ...session.data, time: dayjs.tz().valueOf() }), - symkey, - ) - const signature = await sign(authdata, privkey) - const signatureIv = await sign(iv, privkey) + + // SessionFlashData は残ってないので、 session.data に残るのは SessionData のみ + // state: cb.tsx / continue_to, continue_name: この上で取得した際に消える + // Subject, Audience, Issuer は handleCallback にそろえる + const jwt = await new SignJWT(session.data) + .setSubject('Maximum Auth Data') + .setAudience(registeredData.name) + .setIssuer('maximum-auth') + .setNotBefore('0 sec') + .setIssuedAt() + .setExpirationTime('5 min') + .setProtectedHeader(keypairProtectedHeader) + .sign(privkey) return json({ userdata: session.data, appdata: { ...registeredData, callback: cburl }, - authdata, - iv, - signature, - signatureIv, + token: jwt, }) } @@ -70,11 +70,8 @@ export const meta: MetaFunction = () => { export default function Continue() { const data = useLoaderData() const continueUrl = new URL(data.appdata.callback) - continueUrl.searchParams.set('authdata', data.authdata) - continueUrl.searchParams.set('iv', data.iv) - continueUrl.searchParams.set('signature', data.signature) - continueUrl.searchParams.set('signatureIv', data.signatureIv) const cancelUrl = new URL(data.appdata.callback) + continueUrl.searchParams.set('token', data.token) cancelUrl.searchParams.set('cancel', 'true') return ( diff --git a/webapp/app/routes/go.tsx b/webapp/app/routes/go.tsx index 9062152..4cbac34 100644 --- a/webapp/app/routes/go.tsx +++ b/webapp/app/routes/go.tsx @@ -3,11 +3,7 @@ import type { LoaderFunction, ActionFunction } from '@remix-run/cloudflare' import { redirect } from '@remix-run/cloudflare' -import { - importKey, - verifyToken, - verifyMac, -} from '@saitamau-maximum/auth/internal' +import { importKey, verifyToken } from '@saitamau-maximum/auth/internal' import pubkeyData from '../../data/pubkey.json' import cookieSessionStorage from '../../utils/session.server' @@ -22,7 +18,7 @@ export const loader: LoaderFunction = async ({ context, request }) => { // リクエスト検証 // TODO: ちゃんとテストを書く if ( - ['name', 'pubkey', 'callback', 'token', 'iv', 'mac'].some( + ['name', 'pubkey', 'callback', 'token'].some( key => !params.has(key) || params.getAll(key).length !== 1, ) ) { @@ -53,37 +49,21 @@ export const loader: LoaderFunction = async ({ context, request }) => { throw new Response('invalid request', { status: 400 }) } - let theirPubkey: CryptoKey try { - theirPubkey = await importKey(registeredData.pubkey, 'publicKey') + await importKey(registeredData.pubkey, 'publicKey') } catch (_) { throw new Response('invalid pubkey', { status: 400 }) } - if (!theirPubkey.usages.includes('verify')) - throw new Response('invalid pubkey', { status: 400 }) - - const macVerifyResult = await verifyMac( - params.get('name')!, - params.get('pubkey')!, - params.get('callback')!, - params.get('token')!, - params.get('iv')!, - params.get('mac')!, - theirPubkey, - ) - if (!macVerifyResult) throw new Response('invalid mac', { status: 400 }) const key = await importKey(envvar.SYMKEY, 'symmetric') - const [verifyResult, message] = await verifyToken( - params.get('name')!, - params.get('pubkey')!, - params.get('callback')!, - key, - params.get('token')!, - params.get('iv')!, - ) - - if (!verifyResult) throw new Response(message, { status: 400 }) + const [isvalid, message] = await verifyToken({ + name: params.get('name'), + pubkey: params.get('pubkey'), + callback: params.get('callback'), + symkey: key, + token: params.get('token'), + }) + if (!isvalid) throw new Response(message, { status: 400 }) const { getSession, commitSession } = cookieSessionStorage(envvar) @@ -91,15 +71,6 @@ export const loader: LoaderFunction = async ({ context, request }) => { session.flash('continue_name', encodeURIComponent(params.get('name')!)) session.flash('continue_to', params.get('callback')!) - if (session.get('is_member')) { - return redirect('/continue', { - status: 302, - headers: { - 'Set-Cookie': await commitSession(session), - }, - }) - } - // ref: https://docs.github.com/ja/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps const oauthUrl = new URL('https://github.com/login/oauth/authorize') const oauthParams = new URLSearchParams() diff --git a/webapp/app/routes/token.tsx b/webapp/app/routes/token.tsx index f8d2579..35b7d34 100644 --- a/webapp/app/routes/token.tsx +++ b/webapp/app/routes/token.tsx @@ -36,17 +36,17 @@ export const action: ActionFunction = async ({ request, context }) => { } const key = await importKey(context.cloudflare.env.SYMKEY, 'symmetric') - const [token, iv] = await generateToken( - data.name, - data.pubkey, - data.callback, - key, - ) + const token = await generateToken({ + name: data.name, + pubkey: data.pubkey, + callback: data.callback, + symkey: key, + }) - return new Response(JSON.stringify({ token: btoa(token), iv: btoa(iv) }), { + return new Response(token, { status: 200, headers: { - 'Content-Type': 'application/json', + 'Content-Type': 'text/plain', }, }) } diff --git a/webapp/app/routes/user.tsx b/webapp/app/routes/user.tsx deleted file mode 100644 index a0b6564..0000000 --- a/webapp/app/routes/user.tsx +++ /dev/null @@ -1,75 +0,0 @@ -/* eslint-disable @typescript-eslint/no-non-null-assertion */ - -import type { ActionFunction, LoaderFunction } from '@remix-run/cloudflare' - -import { - importKey, - derivePublicKey, - verify, - decrypt, -} from '@saitamau-maximum/auth/internal' - -import pubkeyData from '../../data/pubkey.json' - -export const action: ActionFunction = async ({ request, context }) => { - if (request.method !== 'POST') { - return new Response('method not allowed', { status: 405 }) - } - if (!request.headers.get('content-type')?.includes('application/json')) { - return new Response('invalid request', { status: 400 }) - } - - interface PostData { - name: string - pubkey: string - data: string - iv: string - sgn1: string // Our hash - sgn2: string // Their data hash - sgn3: string // Their iv hash - } - const data = await request.json() - - if ( - (['name', 'pubkey', 'data', 'iv', 'sgn1', 'sgn2', 'sgn3'] as const).some( - key => !data[key] || typeof data[key] !== 'string', - ) - ) { - return new Response('invalid request', { status: 400 }) - } - - const registeredData = pubkeyData.find( - regdata => regdata.name === data.name && regdata.pubkey === data.pubkey, - ) - - if (registeredData === undefined) { - throw new Response('invalid request', { status: 400 }) - } - - const ourPrivkey = await importKey( - context.cloudflare.env.PRIVKEY, - 'privateKey', - ) - const ourPubkey = await derivePublicKey(ourPrivkey) - const theirPubkey = await importKey(registeredData.pubkey, 'publicKey') - - if ( - !(await verify(data.data!, data.sgn1!, ourPubkey)) || - !(await verify(data.data!, data.sgn2!, theirPubkey)) || - !(await verify(data.iv!, data.sgn3!, theirPubkey)) - ) { - throw new Response('invalid request', { status: 400 }) - } - - const symkey = await importKey(context.cloudflare.env.SYMKEY, 'symmetric') - - return new Response(await decrypt(data.data!, symkey, data.iv!), { - status: 200, - headers: { - 'Content-Type': 'application/json', - }, - }) -} - -export const loader: LoaderFunction = () => - new Response('method not allowed', { status: 405 }) diff --git a/webapp/package.json b/webapp/package.json index 3ee63c4..bd755d5 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -21,8 +21,8 @@ "@remix-run/react": "^2.12.1", "@saitamau-maximum/auth": "workspace:^", "clsx": "^2.1.1", - "dayjs": "^1.11.13", "isbot": "^5.1.17", + "jose": "^5.9.3", "octokit": "^4.0.2", "react": "^18.3.1", "react-dom": "^18.3.1" diff --git a/worker/package.json b/worker/package.json index 28723c1..a876832 100644 --- a/worker/package.json +++ b/worker/package.json @@ -21,6 +21,6 @@ "dependencies": { "@saitamau-maximum/auth": "workspace:^", "cookie": "^0.6.0", - "dayjs": "^1.11.13" + "jose": "^5.9.3" } } diff --git a/worker/src/index.ts b/worker/src/index.ts index 8a5d1b6..16e5c6e 100644 --- a/worker/src/index.ts +++ b/worker/src/index.ts @@ -1,24 +1,18 @@ import { - checkLoggedIn, + getUserInfo, validateRequest as validateRequestFromProxy, } from '@saitamau-maximum/auth' import { derivePublicKey, exportKey, importKey, - sign, handleLogin, handleLogout, handleCallback, handleMe, + keypairProtectedHeader, } from '@saitamau-maximum/auth/internal' -import dayjs from 'dayjs' -import timezone from 'dayjs/plugin/timezone' -import utc from 'dayjs/plugin/utc' - -dayjs.extend(utc) -dayjs.extend(timezone) -dayjs.tz.setDefault('Asia/Tokyo') +import { SignJWT } from 'jose' export default { async fetch( @@ -86,7 +80,12 @@ export default { } // ログインしてない場合はログインページに移動 - if (!(await checkLoggedIn(request, publicKey, dev))) { + const userData = await getUserInfo(request, { + authName, + privateKey: env.PRIVKEY, + dev, + }) + if (!userData) { return handleLogin(request, { authName, privateKey: env.PRIVKEY, @@ -95,9 +94,14 @@ export default { }) } - const now = dayjs.tz().valueOf() - const rand = btoa(crypto.getRandomValues(new Uint8Array(16)).toString()) - const mac = await sign(`${now}___${rand}`, privateKey) + const token = await new SignJWT({}) + .setSubject('Maximum Auth Proxy') + .setIssuer('maximum-auth-proxy') + .setNotBefore('0 sec') + .setIssuedAt() + .setExpirationTime('5 sec') + .setProtectedHeader(keypairProtectedHeader) + .sign(privateKey) // それ以外の場合は Proxy const res = await fetch(url.toString(), { @@ -105,25 +109,16 @@ export default { headers: { ...request.headers, 'X-Maximum-Auth-Pubkey': await exportKey(publicKey), - 'X-Maximum-Auth-Time': now.toString(), - 'X-Maximum-Auth-Key': rand, - 'X-Maximum-Auth-Mac': mac, + 'X-Maximum-Auth-Token': token, }, }) const newHeader = new Headers(res.headers) // Cache-control: private を付加する - if (!res.headers.has('Cache-Control')) { - newHeader.set('Cache-Control', 'private') - } else { - newHeader.set( - 'Cache-Control', - 'private, ' + res.headers.get('Cache-Control'), - ) - } + newHeader.set('Cache-Control', 'private') - return new Response(res.body, { + return new Response(await res.text(), { ...res, headers: newHeader, }) diff --git a/worker/test/index.test.ts b/worker/test/index.test.ts index d2b50dd..8389e54 100644 --- a/worker/test/index.test.ts +++ b/worker/test/index.test.ts @@ -1,29 +1,71 @@ /* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */ -import dayjs from 'dayjs' -import timezone from 'dayjs/plugin/timezone' -import utc from 'dayjs/plugin/utc' +// jsdom だと TextEncoder と Uint8Array の互換性がないみたいなので node でテストする (挙動は同じ...はず) +// https://github.com/vitest-dev/vitest/issues/4043 +// @vitest-environment node + +import { + importKey, + keypairProtectedHeader, +} from '@saitamau-maximum/auth/internal' +import { SignJWT } from 'jose' import type { MockInstance } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import worker from '../src/index' -dayjs.extend(utc) -dayjs.extend(timezone) -dayjs.tz.setDefault('Asia/Tokyo') - const TEST_AUTHPUBKEY = 'eyJrdHkiOiJFQyIsImtleV9vcHMiOlsidmVyaWZ5Il0sImV4dCI6dHJ1ZSwiY3J2IjoiUC01MjEiLCJ4IjoiQVB0MUFSd253eU82WGZEcWFNNFU2SGRRSnlDTUdnUk5wQkwxSXdjdmRfdVRNc3NqMmRCT3VDakFKQ1BRc2VFdVl5blZXdXN6Yi1UM2REQ29ROTlyeVo2NSIsInkiOiJBRjZHd19weTRrZ0xzRUQ2bHlWOVlEd0tTZm1saHQtUHFqSjZ2cXZxZlNmRnhHVG9VR3ZGOHk0OVByclowS3dlM213MFB1cFRHQ0dpLXl5UFpCd1pGenRJIn0=' // const TEST_AUTHPRIVKEY = // 'eyJrdHkiOiJFQyIsImtleV9vcHMiOlsic2lnbiJdLCJleHQiOnRydWUsImNydiI6IlAtNTIxIiwieCI6IkFQdDFBUndud3lPNlhmRHFhTTRVNkhkUUp5Q01HZ1JOcEJMMUl3Y3ZkX3VUTXNzajJkQk91Q2pBSkNQUXNlRXVZeW5WV3VzemItVDNkRENvUTk5cnlaNjUiLCJ5IjoiQUY2R3dfcHk0a2dMc0VENmx5VjlZRHdLU2ZtbGh0LVBxako2dnF2cWZTZkZ4R1RvVUd2Rjh5NDlQcnJaMEt3ZTNtdzBQdXBUR0NHaS15eVBaQndaRnp0SSIsImQiOiJBYU9pNUVtNUM5X0dTT0E5bjV0cEFQLUdCcUFxUDFucU1Bd3ROVUpPSzVwWjNpX09ZVjV5b3RTNnk2ZWRrbWlYMURQVVFDVmVzX3FnU3dKUWhXb2M5XzB0In0=' -// const TEST_PUBKEY = -// 'eyJrdHkiOiJFQyIsImtleV9vcHMiOlsidmVyaWZ5Il0sImV4dCI6dHJ1ZSwiY3J2IjoiUC01MjEiLCJ4IjoiQWJpRUV5QTFGTVhmRklWN0p4c0hUVzJlQXNTX1llZ0hvQlp0R2xaaGRfSXZVSzJmay16RC14dEhmYUFESnFyNXViTEw2eWItTUtLZHktNUhvRnVSNEN1dSIsInkiOiJBZXRiLUhDY19IbGhScUYxT0pHUmlYMS15aWk5SUhzazFJT252dXF6OXlPVFlsOGpSLXNTNHpEaUd6ODBJWHJ2Qm1zTzVPRHZpYWxYN1E5enJ5MHFlS0lkIn0=' +const TEST_PUBKEY = + 'eyJrZXlfb3BzIjpbInZlcmlmeSJdLCJleHQiOnRydWUsImt0eSI6IkVDIiwieCI6IkFmRVhoOTY5VTRxMUo3RDNZQlBpMFY2ODlPdUFaOVJGZS1STHRhSFE4QmUwTEQ2LWQ5dlJ1ZEFFMnlHTE10Z0lMX1drekhSRF9TNjg2M1BkUUZKLTJydnEiLCJ5IjoiQUtpZ0Ftd2FPcF9Vd3V2NXZqOEE4UXFjS2Z3WG42enZNUHU3QWhOdkFDdE5qdC1UdTBvRWg3d0Z5dGJDR3FNaFRYMm01SEJnZlZhbTh5aTRMWkRiSWlYcCIsImNydiI6IlAtNTIxIn0=' +// 'eyJrdHkiOiJFQyIsImtleV9vcHMiOlsidmVyaWZ5Il0sImV4dCI6dHJ1ZSwiY3J2IjoiUC01MjEiLCJ4IjoiQWJpRUV5QTFGTVhmRklWN0p4c0hUVzJlQXNTX1llZ0hvQlp0R2xaaGRfSXZVSzJmay16RC14dEhmYUFESnFyNXViTEw2eWItTUtLZHktNUhvRnVSNEN1dSIsInkiOiJBZXRiLUhDY19IbGhScUYxT0pHUmlYMS15aWk5SUhzazFJT252dXF6OXlPVFlsOGpSLXNTNHpEaUd6ODBJWHJ2Qm1zTzVPRHZpYWxYN1E5enJ5MHFlS0lkIn0=' const TEST_PRIVKEY = 'eyJrdHkiOiJFQyIsImtleV9vcHMiOlsic2lnbiJdLCJleHQiOnRydWUsImNydiI6IlAtNTIxIiwieCI6IkFmRVhoOTY5VTRxMUo3RDNZQlBpMFY2ODlPdUFaOVJGZS1STHRhSFE4QmUwTEQ2LWQ5dlJ1ZEFFMnlHTE10Z0lMX1drekhSRF9TNjg2M1BkUUZKLTJydnEiLCJ5IjoiQUtpZ0Ftd2FPcF9Vd3V2NXZqOEE4UXFjS2Z3WG42enZNUHU3QWhOdkFDdE5qdC1UdTBvRWg3d0Z5dGJDR3FNaFRYMm01SEJnZlZhbTh5aTRMWkRiSWlYcCIsImQiOiJBVVJnWGpLazBtaDlsbDdtVDZvRDJTT09TZEF1bWpITFQtOU1pZnRsd1VNOGpiTlNRMkJxOVlBemNvVkZRRmV5VzEzbVNzY3dUT0dUbTRxZ1QwWDJnV1VmIn0=' +const generateToken = async ({ + subject = 'Maximum Auth Proxy', + issuer = 'maximum-auth-proxy', + exp = '5 sec', + privateKey, +}: { + subject?: string + issuer?: string + exp?: string + privateKey: CryptoKey +}) => { + let jwt = new SignJWT({}) + .setNotBefore('0 sec') + .setIssuedAt() + .setProtectedHeader(keypairProtectedHeader) + if (subject) jwt = jwt.setSubject(subject) + if (issuer) jwt = jwt.setIssuer(issuer) + if (exp) jwt = jwt.setExpirationTime(exp) + return await jwt.sign(privateKey) +} + +const generateCookie = async ({ + privateKey, + ...payload +}: { + [key: string]: unknown + privateKey: CryptoKey +}) => { + return await new SignJWT(payload) + .setSubject('Maximum Auth Data') + .setAudience('Maximum Reverse Proxy') + .setIssuer('Maximum Reverse Proxy') + .setNotBefore('0 sec') + .setIssuedAt() + .setExpirationTime('1 day') + .setProtectedHeader(keypairProtectedHeader) + .sign(privateKey) +} + describe('required options missing', () => { it('throws when PRIVKEY is not provided', async () => { await expect( @@ -186,63 +228,16 @@ describe('prod mode', () => { expect(data.name).toBeTypeOf('string') expect(data.pubkey).toBeTypeOf('string') expect(data.callback).toBeTypeOf('string') - return new Response( - JSON.stringify({ token: btoa('token'), iv: btoa('iv') }), - { - status: 200, - headers: { - 'Content-Type': 'application/json', - }, + return new Response('DUMMY_TOKEN', { + status: 200, + headers: { + 'Content-Type': 'application/json', }, - ) + }) } - // webapp/app/routes/user.tsx - if (path === 'https://auth.server.test/user') { - expect(options).toBeTruthy() - expect(options!.method).toBe('POST') - expect(options!.headers).toBeTruthy() - const headers = new Headers(options!.headers) - expect(headers.get('content-type')).toBeTruthy() - expect( - headers.get('content-type')!.includes('application/json'), - ).toBeTruthy() - expect(options!.body).toBeTruthy() - await expect( - new Promise((resolve, reject) => { - try { - JSON.parse(options!.body as string) - resolve(true) - } catch (e) { - reject(e) - } - }), - ).resolves.toBeTruthy() - const data = JSON.parse(options!.body as string) - expect(data.name).toBeTypeOf('string') - expect(data.pubkey).toBeTypeOf('string') - expect(data.data).toBeTypeOf('string') - expect(data.iv).toBeTypeOf('string') - expect(data.sgn1).toBeTypeOf('string') - expect(data.sgn2).toBeTypeOf('string') - expect(data.sgn3).toBeTypeOf('string') - return new Response( - JSON.stringify({ - id: '120705481', - display_name: 'saitamau-maximum', - is_member: true, - profile_image: - 'https://avatars.githubusercontent.com/u/120705481?v=4', - teams: ['leaders'], - time: dayjs.tz().valueOf(), - }), - { - status: 200, - headers: { - 'Content-Type': 'application/json', - }, - }, - ) + if (path === 'http://localhost/') { + return new Response('Hello World!', { status: 200 }) } console.error('unexpected fetch', path) @@ -280,4 +275,46 @@ describe('prod mode', () => { expect(res.status).toBe(302) expect(res.headers.get('Location')).toContain('https://auth.server.test/go') }) + + it('handles infinity loop', async () => { + const privateKey = await importKey(TEST_PRIVKEY, 'privateKey') + const token = await generateToken({ privateKey }) + const res = await worker.fetch( + new Request('http://localhost/', { + headers: { + 'X-Maximum-Auth-Pubkey': TEST_PUBKEY, + 'X-Maximum-Auth-Token': token, + }, + }), + { + PRIVKEY: TEST_PRIVKEY, + AUTH_DOMAIN: 'https://auth.server.test', + AUTH_PUBKEY: TEST_AUTHPUBKEY, + }, + {} as any, + ) + expect(res.status).toBe(500) + expect(await res.text()).toBe('infinity loop?') + }) + + it('handles valid request', async () => { + const privateKey = await importKey(TEST_PRIVKEY, 'privateKey') + const cookie = await generateCookie({ privateKey, name: 'test' }) + const res = await worker.fetch( + new Request('http://localhost/', { + headers: { + Cookie: `token=${cookie}`, + }, + }), + { + PRIVKEY: TEST_PRIVKEY, + AUTH_DOMAIN: 'https://auth.server.test', + AUTH_PUBKEY: TEST_AUTHPUBKEY, + }, + {} as any, + ) + expect(res.status).toBe(200) + expect(await res.text()).toBe('Hello World!') + expect(res.headers.get('Cache-Control')).toBe('private') + }) })