Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@
"@underlay/web": "workspace:*",
"better-auth": "^1.6.11",
"drizzle-orm": "^0.45.2",
"hono": "^4.12.18"
"hono": "^4.12.18",
"jose": "^6.2.3"
},
"devDependencies": {
"@cloudflare/workers-types": "^5.20261003.1",
Expand Down
11 changes: 7 additions & 4 deletions packages/server/src/api/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
*
* GET|PUT /api/admin/explore-tags {tags: string[]} stewards
* GET|PUT /api/admin/explore-collections {collections: "owner/slug"[]} stewards
* GET /api/kf/summary?kf_org_id= KF Auth, with the internal API key
* GET /api/kf/summary?kf_org_id= the KF console, with its KF-Console-Token
* POST /api/abuse-reports {hash?, url?, reason, contact?}: anyone
* GET /api/admin/abuse-reports stewards: ?status=open|blocked|dismissed
* PATCH /api/admin/abuse-reports/:id stewards: {status: 'dismissed'|'open'}
Expand Down Expand Up @@ -108,11 +108,14 @@ export function adminRoutes() {
}

app.get('/api/kf/summary', async (c) => {
const kfOrgId = c.req.query('kf_org_id')
if (!kfOrgId) return jsonError(c, 400, 'kf_org_id is required')
if (!c.var.kf?.isInternalCall(c.req.header('authorization'))) {
// The token is for the URL registered as underlay's summary_url in kf-console.
const registered = new URL('/api/kf/summary', c.var.config.appUrl).href
const token = c.req.header('kf-console-token')
if (!(await c.var.kf?.isConsoleCall(token, 'GET', registered))) {
return jsonError(c, 401, 'Unauthorized')
}
const kfOrgId = c.req.query('kf_org_id')
if (!kfOrgId) return jsonError(c, 400, 'kf_org_id is required')
const { db } = c.var.ports
const appUrl = c.var.config.appUrl
const orgs = await db
Expand Down
71 changes: 48 additions & 23 deletions packages/server/src/auth/kf.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
/**
* KF Auth beyond sign-in: a user's profile and role (OIDC userinfo with their
* stored token, refreshed when it expires) and, when the deployment has the
* service key, the internal API for the KF orgs a user belongs to. Ported from
* v1's `src/lib/auth.server.ts` and `src/lib/auth-internal.server.ts`.
* stored token, refreshed when it expires), the internal API for the KF orgs a
* user belongs to (called as the app, with its client credentials), and the
* check on calls from the console (its signed `KF-Console-Token`). Ported from
* v1's `src/lib/auth.server.ts` and `src/lib/auth-internal.server.ts`; the
* console side follows kf-console's docs/calling-kf.md.
*
* Every call fails soft: KF Auth being down costs a role or an org list, never
* a page.
Expand All @@ -13,17 +15,20 @@
* account row again for the token the winner stored.
*/
import { and, eq } from 'drizzle-orm'
import { createRemoteJWKSet, customFetch, jwtVerify } from 'jose'

import * as schema from '../db/schema.js'
import type { Db } from '../ports.js'

export interface KfConfig {
/** KF Auth's public origin: console tokens are issued by `<issuerUrl>/api/auth`. */
issuerUrl: string
/** Issuer URL for server-to-server calls. */
internalUrl: string
/** This app's client (`kf_underlay`): the audience of console tokens. */
clientId: string
/** Its secret: with the id, the Basic credentials for KF Auth's internal API. */
clientSecret: string
/** Shared secret for KF Auth's internal API (`AUTH_INTERNAL_API_KEY`); optional. */
internalApiKey?: string | undefined
}

export interface KfProfile {
Expand All @@ -49,18 +54,25 @@ export interface Kf {
* read, the role read in the last 30 s.
*/
role(userId: string): Promise<string | null>
/** KF orgs the user belongs to; [] without the internal API. */
/** KF orgs the user belongs to; [] without a client secret. */
orgs(userId: string): Promise<KfOrg[]>
/** Whether the user belongs to KF org `kfOrgId`. */
entitled(userId: string, kfOrgId: string): Promise<boolean>
/** The user's personal KF org (else their first), for new orgs. */
defaultOrgId(userId: string): Promise<string | null>
/** Whether an Authorization header carries the internal API key. */
isInternalCall(authorization: string | undefined): boolean
/**
* Whether `token` (a request's `KF-Console-Token`) is the console calling
* `method registeredUrl` on this app. The registered URL, not the one the
* request arrived at, which a proxy may have rewritten.
*/
isConsoleCall(token: string | undefined, method: string, registeredUrl: string): Promise<boolean>
}

const PROFILE_TTL_MS = 30_000
const TIMEOUT_MS = 5_000
/** Console tokens live 60 s; KF allows 30 s of clock skew. */
const CONSOLE_TOKEN_MAX_AGE_S = 60
const CLOCK_SKEW_S = 30

export interface KfOptions {
/** How long a failed refresh waits before it reads the account row again. */
Expand All @@ -87,6 +99,13 @@ export function createKf(
const refreshing = new Map<string, Promise<string | null>>()
const call = (url: string, init: RequestInit = {}) =>
fetcher(url, { ...init, signal: AbortSignal.timeout(TIMEOUT_MS) })
const basic = `Basic ${btoa(`${cfg.clientId}:${cfg.clientSecret}`)}`
const issuer = `${cfg.issuerUrl}/api/auth`
// Cached per isolate; fetched again when a token names a new key (keys rotate).
const consoleKeys = createRemoteJWKSet(new URL(`${cfg.internalUrl}/api/auth/jwks`), {
timeoutDuration: TIMEOUT_MS,
[customFetch]: (url, init) => fetcher(url, init),
})

const account = async (userId: string): Promise<AccountRow | null> => {
const [acct] = await db
Expand Down Expand Up @@ -190,10 +209,10 @@ export function createKf(
}

async function internal<T>(path: string): Promise<T | null> {
if (!cfg.internalApiKey) return null
if (!cfg.clientSecret) return null
try {
const res = await call(`${cfg.internalUrl}/api/internal${path}`, {
headers: { authorization: `Bearer ${cfg.internalApiKey}` },
headers: { authorization: basic },
})
return res.ok ? ((await res.json()) as T) : null
} catch {
Expand Down Expand Up @@ -238,7 +257,7 @@ export function createKf(
return hit && Date.now() - hit.at < PROFILE_TTL_MS ? hit.profile.role : null
},
async orgs(userId) {
if (!cfg.internalApiKey) return []
if (!cfg.clientSecret) return []
const [acct] = await db
.select({ accountId: schema.account.accountId })
.from(schema.account)
Expand All @@ -264,19 +283,25 @@ export function createKf(
const orgs = await kf.orgs(userId)
return (orgs.find((o) => o.type === 'personal') ?? orgs[0])?.id ?? null
},
isInternalCall(authorization) {
return (
!!cfg.internalApiKey && timingSafeEqual(authorization ?? '', `Bearer ${cfg.internalApiKey}`)
)
async isConsoleCall(token, method, registeredUrl) {
if (!token) return false
try {
const { payload } = await jwtVerify(token, consoleKeys, {
issuer,
audience: cfg.clientId,
// What tells it apart from an ID token, which has the same issuer and audience.
subject: 'kf-console',
algorithms: ['EdDSA'],
maxTokenAge: CONSOLE_TOKEN_MAX_AGE_S,
clockTolerance: CLOCK_SKEW_S,
})
const url = new URL(registeredUrl)
return payload.htm === method && payload.htu === `${url.origin}${url.pathname}`
} catch (err) {
console.warn('[kf] console token refused:', String(err))
return false
}
},
}
return kf
}

function timingSafeEqual(a: string, b: string): boolean {
const x = new TextEncoder().encode(a)
const y = new TextEncoder().encode(b)
let diff = x.length ^ y.length
for (let i = 0; i < Math.max(x.length, y.length); i++) diff |= (x[i] ?? 0) ^ (y[i] ?? 0)
return diff === 0
}
2 changes: 1 addition & 1 deletion packages/server/src/node/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ const oidc = {
clientId: env.OIDC_CLIENT_ID ?? 'kf_underlay',
clientSecret: env.OIDC_CLIENT_SECRET ?? '',
}
const kf = createKf(db, { ...oidc, internalApiKey: env.AUTH_INTERNAL_API_KEY })
const kf = createKf(db, oidc)
const auth = createAuth(
db,
{ appUrl, secret: env.SESSION_SECRET ?? 'dev-secret-change-me', oidc },
Expand Down
4 changes: 1 addition & 3 deletions packages/server/src/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,6 @@ export interface Env {
RL_USER?: RateLimitBinding
/** Anonymous pages, ARK and the auth routes (lib/limits.ts). */
RL_PAGE?: RateLimitBinding
/** KF Auth's internal API key: KF orgs for new orgs, and /api/kf/summary. Optional. */
AUTH_INTERNAL_API_KEY?: string
REPO_PREFIX?: string
INTERNAL_PREFIX?: string
/**
Expand Down Expand Up @@ -182,11 +180,11 @@ function kfFor(env: Env): Kf {
let kf = kfs.get(env.DB)
if (!kf) {
kf = createKf(primaryDb(env), {
issuerUrl: env.OIDC_ISSUER_URL,
// No private network on Workers: server-to-server calls use the public URL.
internalUrl: env.OIDC_ISSUER_URL,
clientId: env.OIDC_CLIENT_ID,
clientSecret: env.OIDC_CLIENT_SECRET,
internalApiKey: env.AUTH_INTERNAL_API_KEY,
})
kfs.set(env.DB, kf)
}
Expand Down
39 changes: 32 additions & 7 deletions packages/server/test/admin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { afterAll, describe, expect, it } from 'vitest'
import * as schema from '../src/db/schema.js'
import { cleanup } from './harness.js'
import { setup } from './kf-app.js'
import { kfConsole } from './kf-console.js'

afterAll(cleanup)

Expand All @@ -27,18 +28,19 @@ describe('steward and KF summary routes', () => {
}
})

it('answers KF Auth with an org’s collections, with the internal key only', async () => {
it('answers the KF console with an org’s collections, with its token only', async () => {
const { h, call, db } = await setup()
await h.collection('c')
await db.update(schema.organization).set({ kfOrgId: 'kf-1' })
const url = '/api/kf/summary?kf_org_id=kf-1'

expect((await call(url)).status).toBe(401)
expect((await call(url, { authorization: 'Bearer wrong' })).status).toBe(401)
expect((await call('/api/kf/summary', { authorization: 'Bearer internal-key' })).status).toBe(
400,
)
const res = await (await call(url, { authorization: 'Bearer internal-key' })).json()
expect((await call(url, { consoleToken: 'wrong' })).status).toBe(401)
// The old shared key, which the console still sends alongside, is no longer read.
expect((await call(url, { authorization: 'Bearer internal-key' })).status).toBe(401)
expect((await call('/api/kf/summary')).status).toBe(401)
expect((await call('/api/kf/summary', { consoleToken: 'console-token' })).status).toBe(400)
const res = await (await call(url, { consoleToken: 'console-token' })).json()
expect(res.orgs).toEqual([
{
id: 'org1',
Expand All @@ -55,8 +57,31 @@ describe('steward and KF summary routes', () => {
},
])
const none = await call('/api/kf/summary?kf_org_id=kf-none', {
authorization: 'Bearer internal-key',
consoleToken: 'console-token',
})
expect(await none.json()).toEqual({ orgs: [] })
})

it('checks a real console token against the registered summary URL', async () => {
let t!: Awaited<ReturnType<typeof kfConsole>>
const { h, call, db } = await setup({
kf: async (h) => (t = await kfConsole(h.ports.db)).kf,
})
await h.collection('c')
await db.update(schema.organization).set({ kfOrgId: 'kf-1' })
const url = '/api/kf/summary?kf_org_id=kf-1'
const warn = console.warn
console.warn = () => {}
try {
const ok = await call(url, { consoleToken: await t.sign('http://test/api/kf/summary') })
expect(ok.status).toBe(200)
expect((await ok.json()).orgs).toHaveLength(1)
// A token for another app's summary URL, or for another of this app's routes.
for (const htu of ['https://www.pubpub.org/api/kf/summary', 'http://test/api/kf/other']) {
expect((await call(url, { consoleToken: await t.sign(htu) })).status).toBe(401)
}
} finally {
console.warn = warn
}
})
})
2 changes: 1 addition & 1 deletion packages/server/test/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,7 @@ describe('KF org links on organizations', () => {
orgs: async () => [],
entitled: async (_u: string, id: string) => id === 'kf-mine',
defaultOrgId: async () => 'kf-default',
isInternalCall: () => false,
isConsoleCall: async () => false,
}
const auth = createAuth(
h.ports.db,
Expand Down
16 changes: 11 additions & 5 deletions packages/server/test/kf-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type { Principal } from '../src/api/access.js'
import { createApp } from '../src/app.js'
import type { Kf } from '../src/auth/kf.js'
import * as schema from '../src/db/schema.js'
import { harness } from './harness.js'
import { type Harness, harness } from './harness.js'

/** A KF Auth stand-in: user u1 belongs to KF org kf-1 and is a steward. */
export const fakeKf: Kf = {
Expand All @@ -19,20 +19,24 @@ export const fakeKf: Kf = {
: [],
entitled: async (userId, id) => userId === 'u1' && id === 'kf-1',
defaultOrgId: async () => null,
isInternalCall: (h) => h === 'Bearer internal-key',
// Kf's own tests check real tokens; this one, that the route asks about the registered URL.
isConsoleCall: async (token, method, url) =>
token === 'console-token' && method === 'GET' && url === 'http://test/api/kf/summary',
}

/**
* The harness's ports behind an app that knows sessions and keys:
* The harness's ports behind an app that knows sessions and keys (and `fakeKf`, unless
* `opts.kf` makes another):
* `x-test-user` signs in (session `s-<user>`), `x-test-key: read|write|admin|scoped|org`
* (or `scoped-write:<id>`, `scoped-admin:<id>`) makes the caller an API key of that kind.
*/
export async function setup() {
export async function setup(opts: { kf?: (h: Harness) => Kf | Promise<Kf> } = {}) {
const h = await harness()
const kf = opts.kf ? await opts.kf(h) : fakeKf
const app = createApp(() => ({
ports: h.ports,
config: { appUrl: 'http://test', deployment: 'test' },
kf: fakeKf,
kf,
authenticate: async (req): Promise<Principal | null> => {
const user = req.headers.get('x-test-user')
if (!user) return null
Expand Down Expand Up @@ -62,12 +66,14 @@ export async function setup() {
key?: string
json?: unknown
authorization?: string
consoleToken?: string
} = {},
) => {
const headers = new Headers()
if (init.user) headers.set('x-test-user', init.user)
if (init.key) headers.set('x-test-key', init.key)
if (init.authorization) headers.set('authorization', init.authorization)
if (init.consoleToken) headers.set('kf-console-token', init.consoleToken)
if (init.json !== undefined) headers.set('content-type', 'application/json')
return app.fetch(
new Request(`http://test${path}`, {
Expand Down
52 changes: 52 additions & 0 deletions packages/server/test/kf-console.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/**
* KF Auth as the console's token issuer, without the network: a local EdDSA key,
* its JWKS served by a stub fetch, and a signer for `KF-Console-Token`s.
*/
import { exportJWK, generateKeyPair, SignJWT } from 'jose'

import { createKf } from '../src/auth/kf.js'
import type { Db } from '../src/ports.js'

export const ISSUER_URL = 'https://kf.test'
export const INTERNAL_URL = 'http://kf-internal'
export const CLIENT_ID = 'kf_underlay'

export interface TokenOptions {
iss?: string
aud?: string
sub?: string
iat?: number
exp?: number
key?: CryptoKey
}

export async function kfConsole(db: Db) {
const { privateKey, publicKey } = await generateKeyPair('EdDSA', { extractable: true })
const jwk = { ...(await exportJWK(publicKey)), kid: 'k1', alg: 'EdDSA', use: 'sig' }
let jwksFetches = 0
const fetcher = (async (input: RequestInfo | URL) => {
if (String(input) !== `${INTERNAL_URL}/api/auth/jwks`)
return new Response('{}', { status: 404 })
jwksFetches++
return Response.json({ keys: [jwk] })
}) as typeof fetch
const kf = createKf(
db,
{ issuerUrl: ISSUER_URL, internalUrl: INTERNAL_URL, clientId: CLIENT_ID, clientSecret: 's' },
fetcher,
)
/** A console token for `GET htu`, as KF signs them; `claims` and `opts` change it. */
const sign = (htu: string, claims: Record<string, unknown> = {}, opts: TokenOptions = {}) => {
const now = Math.floor(Date.now() / 1000)
return new SignJWT({ htm: 'GET', htu, ...claims })
.setProtectedHeader({ alg: 'EdDSA', kid: 'k1', typ: 'JWT' })
.setIssuer(opts.iss ?? `${ISSUER_URL}/api/auth`)
.setAudience(opts.aud ?? CLIENT_ID)
.setSubject(opts.sub ?? 'kf-console')
.setIssuedAt(opts.iat ?? now)
.setExpirationTime(opts.exp ?? now + 60)
.setJti(crypto.randomUUID())
.sign(opts.key ?? privateKey)
}
return { kf, sign, jwksFetches: () => jwksFetches }
}
Loading
Loading