mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
almost into a room
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
import { Hono } from 'hono'
|
||||
import { useWorkersLogger } from 'workers-tagged-logger'
|
||||
|
||||
import { logger, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||
|
||||
import type { App } from './context'
|
||||
import { generateToken, TOKEN_TTL_SECONDS } from './jwt'
|
||||
|
||||
/** OAuth scopes granted by `/connect/token`. */
|
||||
const TOKEN_SCOPE =
|
||||
'offline_access profile rn rn.accounts rn.accounts.gc rn.api rn.chat rn.clubs rn.commerce rn.match.read rn.match.write rn.notify rn.rooms rn.storage'
|
||||
|
||||
const app = new Hono<App>()
|
||||
.use(
|
||||
'*',
|
||||
// middleware
|
||||
(c, next) =>
|
||||
useWorkersLogger(c.env.NAME, {
|
||||
environment: c.env.ENVIRONMENT,
|
||||
release: c.env.SENTRY_RELEASE,
|
||||
})(c, next)
|
||||
)
|
||||
|
||||
.onError(withOnError())
|
||||
.notFound(withNotFound())
|
||||
|
||||
// EAC challenge — a fresh GUID, JSON-quoted, served as plain text.
|
||||
.get('/eac/challenge', (c) => c.text(`"${crypto.randomUUID()}"`))
|
||||
|
||||
// Cached logins for a platform id. No DB binding yet — always empty.
|
||||
.get('/cachedlogin/forplatformid/:platform/:id', (c) => {
|
||||
const { platform, id } = c.req.param()
|
||||
logger.info('cached login lookup', { platform, id })
|
||||
// TODO: query CachedLogins once a DB binding exists.
|
||||
return c.json([] as unknown[])
|
||||
})
|
||||
|
||||
// OAuth token endpoint — accepts a form-urlencoded body and issues a JWT.
|
||||
.post('/connect/token', async (c) => {
|
||||
let accountId = '1'
|
||||
let platformId = ''
|
||||
// `platform` is never populated from the body in the C# source either.
|
||||
const platform = ''
|
||||
|
||||
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
|
||||
console.log(body);
|
||||
|
||||
const accessToken = await generateToken(accountId, platformId, platform)
|
||||
|
||||
// TODO: once a DB binding exists, remove any RoomInstance owned by accountId.
|
||||
|
||||
return c.json({
|
||||
access_token: accessToken,
|
||||
expires_in: TOKEN_TTL_SECONDS,
|
||||
token_type: 'Bearer',
|
||||
refresh_token: `${crypto.randomUUID().replace(/-/g, '').toUpperCase()}-1`,
|
||||
scope: TOKEN_SCOPE,
|
||||
key: '8oQ+e+WQaOBPbEcakhqs3dwZZdOmmyDUmJSD9u4AHMY=',
|
||||
})
|
||||
})
|
||||
|
||||
// Developer role lookup. Not implemented in the C# source either.
|
||||
.get('/role/developer/:id', (c) => {
|
||||
const { id } = c.req.param()
|
||||
logger.info('developer role lookup', { id })
|
||||
// TODO: implement
|
||||
return c.json({ success: true })
|
||||
})
|
||||
|
||||
export default app
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { HonoApp } from '@repo/hono-helpers'
|
||||
import type { SharedHonoEnv, SharedHonoVariables } from '@repo/hono-helpers/src/types'
|
||||
|
||||
export type Env = SharedHonoEnv & {
|
||||
// add additional Bindings here
|
||||
}
|
||||
|
||||
/** Variables can be extended */
|
||||
export type Variables = SharedHonoVariables
|
||||
|
||||
export interface App extends HonoApp {
|
||||
Bindings: Env
|
||||
Variables: Variables
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Minimal HS256 JWT generation, mirroring the C# `JwtTokenService.GenerateToken`.
|
||||
*
|
||||
* No real signing-key binding yet — uses a placeholder dev secret. Swap this for
|
||||
* a secret binding (e.g. `c.env.JWT_SECRET`) before this is used for anything real.
|
||||
*/
|
||||
const DEV_SECRET = 'dev-insecure-signing-key-change-me'
|
||||
|
||||
/** Token lifetime in seconds (matches `expires_in` in the C# response). */
|
||||
export const TOKEN_TTL_SECONDS = 3600
|
||||
|
||||
function base64url(input: ArrayBuffer | string): string {
|
||||
const bytes =
|
||||
typeof input === 'string' ? new TextEncoder().encode(input) : new Uint8Array(input)
|
||||
let binary = ''
|
||||
for (const byte of bytes) {
|
||||
binary += String.fromCharCode(byte)
|
||||
}
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
}
|
||||
|
||||
export async function generateToken(
|
||||
accountId: string,
|
||||
platformId: string,
|
||||
platform: string,
|
||||
secret: string = DEV_SECRET
|
||||
): Promise<string> {
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const header = { alg: 'HS256', typ: 'JWT' }
|
||||
const payload = {
|
||||
sub: accountId,
|
||||
platform_id: platformId,
|
||||
platform,
|
||||
iat: now,
|
||||
exp: now + TOKEN_TTL_SECONDS,
|
||||
}
|
||||
|
||||
const signingInput = `${base64url(JSON.stringify(header))}.${base64url(JSON.stringify(payload))}`
|
||||
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
new TextEncoder().encode(secret),
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign']
|
||||
)
|
||||
const signature = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(signingInput))
|
||||
|
||||
return `${signingInput}.${base64url(signature)}`
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { exports } from 'cloudflare:workers'
|
||||
import { describe, expect, test } from 'vitest'
|
||||
|
||||
import '../../auth.app'
|
||||
|
||||
const ORIGIN = 'https://auth.rec.djdevin.net'
|
||||
|
||||
describe('auth worker routes', () => {
|
||||
test('GET /eac/challenge returns a JSON-quoted GUID', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/eac/challenge`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.headers.get('content-type')).toContain('text/plain')
|
||||
expect(await res.text()).toMatch(/^"[0-9a-f-]{36}"$/)
|
||||
})
|
||||
|
||||
test('GET /cachedlogin/forplatformid/:platform/:id returns empty list', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/cachedlogin/forplatformid/1/abc123`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual([])
|
||||
})
|
||||
|
||||
test('POST /connect/token issues a bearer token', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/connect/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'account_id=42&platform_id=steam-123',
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const json = (await res.json()) as { access_token: string; token_type: string; expires_in: number }
|
||||
expect(json.token_type).toBe('Bearer')
|
||||
expect(json.expires_in).toBe(3600)
|
||||
// header.payload.signature
|
||||
expect(json.access_token.split('.')).toHaveLength(3)
|
||||
})
|
||||
|
||||
test('GET /role/developer/:id returns ok', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/role/developer/42`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ success: true })
|
||||
})
|
||||
|
||||
test('unknown path returns 404', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/nope`)
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user