diff --git a/src/lib/oauth/pages.ts b/src/lib/oauth/pages.ts index ec5a121..b9719b0 100644 --- a/src/lib/oauth/pages.ts +++ b/src/lib/oauth/pages.ts @@ -1,5 +1,7 @@ // Server-rendered HTML pages for the OAuth flow (errors, consent). +import type { ResourceInfo } from "./resource"; + export function escapeHtml(value: string): string { return value .replaceAll("&", "&") @@ -225,13 +227,26 @@ const CONSENT_STYLES = ` background: rgba(12, 23, 19, 0.4); border: 1px solid var(--paper-dim); } - .scope-title { + .scope-title, .resource-title { font-size: 0.75rem; color: var(--paper-dim); text-transform: uppercase; letter-spacing: 0.1rem; margin-bottom: 0.75rem; } + .resources { margin-bottom: 1.25rem; } + .resource { + display: flex; align-items: center; gap: 0.75rem; + padding: 0.6rem 0.75rem; border: 1px solid var(--rule, rgba(255,255,255,0.12)); + border-radius: 0.6rem; margin-bottom: 0.4rem; + } + .resource-logo { + width: 32px; height: 32px; flex: none; display: flex; align-items: center; + justify-content: center; font-size: 1.1rem; border-radius: 0.4rem; overflow: hidden; + } + .resource-logo img { width: 100%; height: 100%; object-fit: cover; } + .resource-name { font-weight: 600; font-size: 0.9rem; } + .resource-host { font-size: 0.75rem; color: var(--paper-dim); } .scope-list { list-style: none; display: flex; @@ -340,6 +355,7 @@ export interface ConsentPageOptions { codeChallenge: string; me: string | null; nonce: string | null; + resources?: ResourceInfo[]; csrfToken: string; } @@ -351,6 +367,30 @@ const SCOPE_DESCRIPTIONS: Record = { }; export function consentPage(opts: ConsentPageOptions): Response { + // RFC 8707: show WHAT the client is being granted access to (name + icon from + // the resource's PRM), and carry each resource through the form as a hidden + // field so the POST binds the token to the same audience. + const resources = opts.resources ?? []; + const resourceBlock = resources.length + ? `
+
access to
+ ${resources + .map( + (r) => `
+ +
+
${escapeHtml(r.name)}
+
${escapeHtml(r.host)}
+
+
`, + ) + .join("")} +
` + : ""; + const resourceHidden = resources + .map((r) => ``) + .join(""); + const scopeItems = opts.scopes .map((scope) => { const isProfile = scope === "profile"; @@ -382,12 +422,14 @@ export function consentPage(opts: ConsentPageOptions): Response { ${opts.me ? `
requesting identity ${escapeHtml(opts.me)}
` : ""}
+ ${resourceBlock}
requested permissions
    ${scopeItems}
+ ${resourceHidden} diff --git a/src/lib/oauth/resource.ts b/src/lib/oauth/resource.ts new file mode 100644 index 0000000..da32f74 --- /dev/null +++ b/src/lib/oauth/resource.ts @@ -0,0 +1,77 @@ +import { safeFetch, validateExternalURL } from "../ssrf-safe-fetch"; + +// RFC 8707 Resource Indicators. +// +// A `resource` value identifies the resource server (audience) a token is meant +// for. ยง2 requires each to be an absolute URI WITHOUT a fragment. We normalize +// (origin + path, drop a trailing slash) so the string a client sends, the value +// we stamp as the token's audience, and the id the resource server checks all +// compare byte-for-byte. + +/** Validate + normalize requested resources. Returns the list (deduped, may be + * empty) or null if ANY value is malformed (โ†’ the caller should reply + * `invalid_target`). */ +export function validateResources(values: string[]): string[] | null { + const out: string[] = []; + for (const raw of values) { + if (!raw) continue; + let u: URL; + try { + u = new URL(raw); + } catch { + return null; + } + if ((u.protocol !== "https:" && u.protocol !== "http:") || u.hash) return null; + const norm = u.origin + u.pathname.replace(/\/+$/, ""); + if (!out.includes(norm)) out.push(norm); + } + return out; +} + +/** DB storage form: a space-separated list (URIs never contain spaces), or null. */ +export function resourcesToStored(resources: string[]): string | null { + return resources.length > 0 ? resources.join(" ") : null; +} + +/** Read the stored form back into a list. */ +export function storedToResources(stored: string | null | undefined): string[] { + return stored ? stored.split(" ").filter(Boolean) : []; +} + +export interface ResourceInfo { + id: string; + name: string; + host: string; + logo?: string; +} + +/** + * Resolve requested resources to friendly display info for the consent screen + * via each resource's RFC 9728 Protected Resource Metadata (`resource_name` and + * an optional `logo_uri`). Best-effort + SSRF-safe: a resource with no PRM, an + * unreachable one, or a bad logo URL just falls back to its hostname. + */ +export async function resourceDisplay(resources: string[]): Promise { + return Promise.all(resources.map(fetchResourceInfo)); +} + +async function fetchResourceInfo(id: string): Promise { + let host = id; + try { + host = new URL(id).host; + } catch { + /* keep id as host */ + } + const info: ResourceInfo = { id, name: host, host }; + try { + const prmUrl = `${id.replace(/\/+$/, "")}/.well-known/oauth-protected-resource`; + const res = await safeFetch(prmUrl, { timeout: 3000, headers: { accept: "application/json" } }); + if (!res.success) return info; + const prm = (await res.data.json()) as { resource_name?: string; logo_uri?: string }; + if (prm.resource_name) info.name = prm.resource_name; + if (prm.logo_uri && validateExternalURL(prm.logo_uri).safe) info.logo = prm.logo_uri; + } catch { + /* best-effort: hostname fallback already set */ + } + return info; +} diff --git a/src/migrations/012_add_resource_indicators.sql b/src/migrations/012_add_resource_indicators.sql new file mode 100644 index 0000000..897bd34 --- /dev/null +++ b/src/migrations/012_add_resource_indicators.sql @@ -0,0 +1,9 @@ +-- RFC 8707 Resource Indicators: bind an access token to a specific resource +-- server (its `aud`). The requested `resource` rides from the authorization +-- request โ†’ the auth code / device code โ†’ the issued token, and is echoed as +-- `aud` from token introspection so a resource server can verify the token was +-- minted for IT and reject one intended for a different service. +-- NULL means the token is unscoped (no resource requested) โ€” today's behaviour. +ALTER TABLE authcodes ADD COLUMN resource TEXT DEFAULT NULL; +ALTER TABLE device_codes ADD COLUMN resource TEXT DEFAULT NULL; +ALTER TABLE tokens ADD COLUMN resource TEXT DEFAULT NULL; diff --git a/src/routes/oauth/authorize.ts b/src/routes/oauth/authorize.ts index 178b0bc..297a27b 100644 --- a/src/routes/oauth/authorize.ts +++ b/src/routes/oauth/authorize.ts @@ -3,6 +3,12 @@ import { db } from "../../db"; import { ensureApp } from "../../lib/oauth/client-metadata"; import { consentPage, errorPage, escapeHtml } from "../../lib/oauth/pages"; import { canonicalizeURL } from "../../lib/oauth/urls"; +import { + resourceDisplay, + type ResourceInfo, + resourcesToStored, + validateResources, +} from "../../lib/oauth/resource"; import { getCsrfToken, getUserFromCookie } from "../../lib/session"; import { token } from "./token"; @@ -23,6 +29,18 @@ export async function authorizeGet(req: Request): Promise { const me = params.get("me"); const nonce = params.get("nonce"); // OIDC nonce parameter + // RFC 8707 resource indicators (may repeat). A malformed one is invalid_target. + const requestedResources = validateResources(params.getAll("resource")); + if (requestedResources === null) { + return errorPage({ + title: "Invalid Resource", + message: + "The authorization request included a resource parameter that is not an absolute URI (or carries a fragment).", + hint: "Each resource must be an absolute https URI with no fragment, e.g. https://api.example.com.", + }); + } + const resourceStored = resourcesToStored(requestedResources); + // Step 1: client_id and redirect_uri must exist (can't redirect without them) if (!rawClientId || !rawRedirectUri) { return new Response( @@ -186,7 +204,7 @@ export async function authorizeGet(req: Request): Promise { const expiresAt = now + AUTH_CODE_TTL; db.query( - "INSERT INTO authcodes (code, user_id, client_id, redirect_uri, scopes, code_challenge, expires_at, me, nonce, auth_time) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "INSERT INTO authcodes (code, user_id, client_id, redirect_uri, scopes, code_challenge, expires_at, me, nonce, auth_time, resource) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ).run( code, user.userId, @@ -198,6 +216,7 @@ export async function authorizeGet(req: Request): Promise { me, nonce, now, // auth_time - user already authenticated + resourceStored, ); db.query( @@ -224,10 +243,11 @@ export async function authorizeGet(req: Request): Promise { requestedScopes, me, nonce, + requestedResources, ); } -function showConsentScreen( +async function showConsentScreen( req: Request, user: { username: string }, clientId: string, @@ -237,7 +257,8 @@ function showConsentScreen( scopes: string[], me: string | null, nonce: string | null, -): Response { + resources: string[], +): Promise { const appData = db .query("SELECT name, logo_url, description FROM apps WHERE client_id = ?") .get(clientId) as @@ -265,6 +286,10 @@ function showConsentScreen( } } + // Resolve the requested resources to friendly name + icon (via their PRM) so + // the consent screen shows WHAT kloe is being granted access to, not a URL. + const resourceInfos: ResourceInfo[] = resources.length ? await resourceDisplay(resources) : []; + return consentPage({ username: user.username, appName, @@ -278,6 +303,7 @@ function showConsentScreen( codeChallenge, me, nonce, + resources: resourceInfos, csrfToken: getCsrfToken(req) || "", }); } @@ -380,6 +406,14 @@ export async function authorizePost(req: Request): Promise { // Get the scopes the user actually approved (from checkboxes) const approvedScopes = formData.getAll("scope") as string[]; + // Resource indicators carried through the consent form (hidden fields). + // Re-validate โ€” the POST body is attacker-controllable. + const approvedResources = validateResources(formData.getAll("resource") as string[]); + if (approvedResources === null) { + return new Response("invalid_target", { status: 400 }); + } + const resourceStored = resourcesToStored(approvedResources); + // Profile scope is always required and included via hidden input if (approvedScopes.length === 0 || !approvedScopes.includes("profile")) { return new Response("Invalid scope selection", { status: 400 }); @@ -390,7 +424,7 @@ export async function authorizePost(req: Request): Promise { const expiresAt = now + AUTH_CODE_TTL; db.query( - "INSERT INTO authcodes (code, user_id, client_id, redirect_uri, scopes, code_challenge, expires_at, me, nonce, auth_time) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "INSERT INTO authcodes (code, user_id, client_id, redirect_uri, scopes, code_challenge, expires_at, me, nonce, auth_time, resource) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ).run( code, user.userId, @@ -402,6 +436,7 @@ export async function authorizePost(req: Request): Promise { me, nonce, now, // auth_time + resourceStored, ); // Store or update permission grant diff --git a/src/routes/oauth/device.ts b/src/routes/oauth/device.ts index 5c001d4..d0e9aef 100644 --- a/src/routes/oauth/device.ts +++ b/src/routes/oauth/device.ts @@ -7,6 +7,7 @@ import { oauthError, parseBody, } from "../../lib/oauth/errors"; +import { resourcesToStored, validateResources } from "../../lib/oauth/resource"; import { canonicalizeURL } from "../../lib/oauth/urls"; const DEVICE_CODE_TTL = 600; // 10 minutes @@ -69,7 +70,14 @@ export async function deviceAuthorization(req: Request): Promise { ); } - const { client_id: rawClientId, scope } = body; + const { client_id: rawClientId, scope, resource: rawResource } = body; + + // RFC 8707 resource indicator (device flow carries it from the start). + const resources = validateResources(rawResource ? [rawResource] : []); + if (resources === null) { + return oauthError(400, "invalid_target", "resource must be an absolute URI without a fragment"); + } + const resourceStored = resourcesToStored(resources); if (!rawClientId) { return oauthError( @@ -99,7 +107,7 @@ export async function deviceAuthorization(req: Request): Promise { const expiresAt = now + DEVICE_CODE_TTL; db.query( - "INSERT INTO device_codes (device_code, user_code, client_id, scope, expires_at, interval) VALUES (?, ?, ?, ?, ?, ?)", + "INSERT INTO device_codes (device_code, user_code, client_id, scope, expires_at, interval, resource) VALUES (?, ?, ?, ?, ?, ?, ?)", ).run( deviceCode, userCode, @@ -107,6 +115,7 @@ export async function deviceAuthorization(req: Request): Promise { scope || "profile", expiresAt, POLL_INTERVAL, + resourceStored, ); const origin = process.env.ORIGIN || "http://localhost:3000"; diff --git a/src/routes/oauth/token.ts b/src/routes/oauth/token.ts index a48501c..636bf15 100644 --- a/src/routes/oauth/token.ts +++ b/src/routes/oauth/token.ts @@ -109,7 +109,7 @@ async function handleRefreshTokenGrant( const tokenData = db .query( - "SELECT id, user_id, client_id, scope, refresh_expires_at, revoked, rotated, family FROM tokens WHERE refresh_token = ?", + "SELECT id, user_id, client_id, scope, refresh_expires_at, revoked, rotated, family, resource FROM tokens WHERE refresh_token = ?", ) .get(refresh_token) as | { @@ -121,6 +121,7 @@ async function handleRefreshTokenGrant( revoked: number; rotated: number; family: string | null; + resource: string | null; } | undefined; @@ -193,7 +194,7 @@ async function handleRefreshTokenGrant( } db.query( - "INSERT INTO tokens (token, user_id, client_id, scope, expires_at, refresh_token, refresh_expires_at, family) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + "INSERT INTO tokens (token, user_id, client_id, scope, expires_at, refresh_token, refresh_expires_at, family, resource) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", ).run( newAccessToken, tokenData.user_id, @@ -203,6 +204,7 @@ async function handleRefreshTokenGrant( newRefreshToken, refreshExpiresAt, family, + tokenData.resource, // RFC 8707: a refreshed token keeps its audience ); const user = db @@ -269,7 +271,7 @@ async function handleDeviceCodeGrant( const deviceCode = db .query( - "SELECT id, client_id, scope, expires_at, interval, last_polled_at, status, user_id FROM device_codes WHERE device_code = ?", + "SELECT id, client_id, scope, expires_at, interval, last_polled_at, status, user_id, resource FROM device_codes WHERE device_code = ?", ) .get(device_code) as | { @@ -281,6 +283,7 @@ async function handleDeviceCodeGrant( last_polled_at: number | null; status: string; user_id: number | null; + resource: string | null; } | undefined; @@ -383,7 +386,7 @@ async function handleDeviceCodeGrant( const refreshExpiresAt = issueRefresh ? now + REFRESH_TOKEN_TTL : null; db.query( - "INSERT INTO tokens (token, user_id, client_id, scope, expires_at, refresh_token, refresh_expires_at, family) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + "INSERT INTO tokens (token, user_id, client_id, scope, expires_at, refresh_token, refresh_expires_at, family, resource) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", ).run( accessToken, deviceCode.user_id, @@ -393,6 +396,7 @@ async function handleDeviceCodeGrant( refreshToken, refreshExpiresAt, crypto.randomUUID(), + deviceCode.resource, ); const profile: Record = {}; @@ -525,7 +529,7 @@ async function handleAuthorizationCodeGrant( const authcode = db .query( - "SELECT user_id, client_id, redirect_uri, scopes, code_challenge, expires_at, used, me, nonce, auth_time FROM authcodes WHERE code = ?", + "SELECT user_id, client_id, redirect_uri, scopes, code_challenge, expires_at, used, me, nonce, auth_time, resource FROM authcodes WHERE code = ?", ) .get(code) as | { @@ -539,6 +543,7 @@ async function handleAuthorizationCodeGrant( me: string | null; nonce: string | null; auth_time: number | null; + resource: string | null; } | undefined; @@ -651,7 +656,7 @@ async function handleAuthorizationCodeGrant( const refreshExpiresAt = issueRefresh ? now + REFRESH_TOKEN_TTL : null; db.query( - "INSERT INTO tokens (token, user_id, client_id, scope, expires_at, refresh_token, refresh_expires_at, family) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + "INSERT INTO tokens (token, user_id, client_id, scope, expires_at, refresh_token, refresh_expires_at, family, resource) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", ).run( accessToken, authcode.user_id, @@ -661,6 +666,7 @@ async function handleAuthorizationCodeGrant( refreshToken, refreshExpiresAt, crypto.randomUUID(), + authcode.resource, // RFC 8707: bind the token to the requested audience ); const response: Record = { @@ -748,7 +754,7 @@ export async function tokenIntrospect(req: Request): Promise { const tokenData = db .query( - "SELECT t.user_id, t.client_id, t.scope, t.expires_at, t.revoked, t.created_at, u.username FROM tokens t JOIN users u ON t.user_id = u.id WHERE t.token = ?", + "SELECT t.user_id, t.client_id, t.scope, t.expires_at, t.revoked, t.created_at, t.resource, u.username FROM tokens t JOIN users u ON t.user_id = u.id WHERE t.token = ?", ) .get(tokenValue) as | { @@ -758,6 +764,7 @@ export async function tokenIntrospect(req: Request): Promise { expires_at: number; revoked: number; created_at: number; + resource: string | null; username: string; } | undefined; @@ -778,6 +785,12 @@ export async function tokenIntrospect(req: Request): Promise { const origin = process.env.ORIGIN || "http://localhost:3000"; const meValue = user?.url || `${origin}/u/${tokenData.username}`; + // RFC 8707 / 7662: echo the token's audience so a resource server can + // confirm the token was minted for it. One resource โ†’ a string, several โ†’ + // an array; omitted entirely when the token is unscoped. + const resources = tokenData.resource ? tokenData.resource.split(" ").filter(Boolean) : []; + const aud = resources.length === 1 ? resources[0] : resources.length > 1 ? resources : undefined; + return Response.json({ active: true, sub: meValue, @@ -787,6 +800,7 @@ export async function tokenIntrospect(req: Request): Promise { exp: tokenData.expires_at, iat: tokenData.created_at, username: tokenData.username, + ...(aud ? { aud } : {}), }); } catch (error) { console.error("Token introspection error:", error);