diff --git a/src/content/docs.md b/src/content/docs.md index edb22d0..b538fce 100644 --- a/src/content/docs.md +++ b/src/content/docs.md @@ -112,6 +112,7 @@ Copy this themed button for your app's login page. It matches Indiko's visual st | `/userinfo` | GET | Get user profile data with bearer token | | `/auth/device` | POST | Request device and user codes (RFC 8628) | | `/device` | GET | User verification page (enter code, approve/deny) | +| `/oauth/register` | POST | Dynamic client registration (RFC 7591) | | `/u/:username` | GET | Public user profile (h-card with discovery links) | ### authentication endpoints @@ -471,3 +472,38 @@ Apps that an admin registers manually get additional features: - No metadata publishing required > **Choosing a type:** Use auto-registration for most apps. Pre-register when you need a client secret, role-based access control, or custom branding on the consent screen. + +## dynamic client registration (RFC 7591) + +Register a confidential client programmatically instead of asking an admin. Returns an opaque `client_id` and `client_secret` for use with the authorization code flow. + +```http +POST {{origin}}/oauth/register +Content-Type: application/json + +{ + "redirect_uris": ["https://myapp.example.com/callback"], + "client_name": "My App", + "logo_uri": "https://myapp.example.com/logo.png" +} +``` + +Response `201 Created`: + +```json +{ + "client_id": "ikc_abc123...", + "client_secret": "iks_xyz789...", + "client_id_issued_at": 1735686000, + "client_secret_expires_at": 0, + "redirect_uris": ["https://myapp.example.com/callback"], + "client_name": "My App", + "token_endpoint_auth_method": "client_secret_post", + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"] +} +``` + +> **Store the secret now:** `client_secret` is returned in plaintext **only once**, in this response. It is stored hashed and never shown again. Use it as `client_secret` in the [token exchange](#4-exchange-code-for-token). If you lose it, an admin must regenerate it. + +> **When to use DCR vs auto-registration:** DCR gives you a confidential client with a secret (better for server-side apps that can keep one). Auto-registration (using a URL as your `client_id`) is simpler for public clients and needs no secret. diff --git a/src/index.ts b/src/index.ts index 6469c1b..33f9d04 100644 --- a/src/index.ts +++ b/src/index.ts @@ -53,6 +53,7 @@ import { updateInvite, } from "./routes/oauth/invites"; import { userProfile } from "./routes/oauth/profile"; +import { registerClient } from "./routes/oauth/register"; import { token, tokenIntrospect, tokenRevoke } from "./routes/oauth/token"; import { logout, userinfo } from "./routes/oauth/userinfo"; import { @@ -251,6 +252,10 @@ Policy: https://tangled.org/dunkirk.sh/indiko/blob/main/SECURITY.md if (req.method === "POST") return await authorizePost(req); return new Response("Method not allowed", { status: 405 }); }, + "/oauth/register": async (req: Request) => { + if (req.method === "POST") return await registerClient(req); + return new Response("Method not allowed", { status: 405 }); + }, "/auth/device": async (req: Request) => { if (req.method === "POST") return await deviceAuthorization(req); return new Response("Method not allowed", { status: 405 }); diff --git a/src/routes/oauth/discovery.ts b/src/routes/oauth/discovery.ts index f6b24b4..ee33c7d 100644 --- a/src/routes/oauth/discovery.ts +++ b/src/routes/oauth/discovery.ts @@ -121,6 +121,7 @@ export function indieauthMetadata(): Response { client_id_metadata_document_supported: true, authorization_response_iss_parameter_supported: true, device_authorization_endpoint: `${origin}/auth/device`, + registration_endpoint: `${origin}/oauth/register`, }; return Response.json(metadata, { diff --git a/src/routes/oauth/register.ts b/src/routes/oauth/register.ts new file mode 100644 index 0000000..43c2509 --- /dev/null +++ b/src/routes/oauth/register.ts @@ -0,0 +1,112 @@ +import { nanoid } from "nanoid"; +import { db } from "../../db"; +import { NO_STORE_HEADERS, oauthError } from "../../lib/oauth/errors"; +import { hashSecret } from "../../lib/secrets"; + +function generateClientId(): string { + return `ikc_${nanoid(21)}`; // indiko client +} + +function generateClientSecret(): string { + return `iks_${nanoid(43)}`; // indiko secret +} + +interface RegisterBody { + redirect_uris?: unknown; + client_name?: unknown; + logo_uri?: unknown; + client_uri?: unknown; + token_endpoint_auth_method?: unknown; +} + +function asStringArray(value: unknown): string[] | null { + if (!Array.isArray(value)) return null; + const out: string[] = []; + for (const item of value) { + if (typeof item !== "string" || !item.trim()) return null; + out.push(item); + } + return out; +} + +function isValidRedirectUri(uri: string): boolean { + try { + const url = new URL(uri); + return url.protocol === "https:" || url.protocol === "http:"; + } catch { + return false; + } +} + +// POST /oauth/register — RFC 7591 Dynamic Client Registration. +// Registers a confidential client (opaque client_id + client_secret). +// No auth required per RFC 7591; rate limiting is out of scope here. +export async function registerClient(req: Request): Promise { + let body: RegisterBody; + try { + body = (await req.json()) as RegisterBody; + } catch { + return oauthError(400, "invalid_client_metadata", "Body must be JSON"); + } + + const redirectUris = asStringArray(body.redirect_uris); + if (!redirectUris || redirectUris.length === 0) { + return oauthError( + 400, + "invalid_redirect_uri", + "redirect_uris must be a non-empty array of URIs", + ); + } + + for (const uri of redirectUris) { + if (!isValidRedirectUri(uri)) { + return oauthError( + 400, + "invalid_redirect_uri", + `Invalid redirect_uri: ${uri}`, + ); + } + } + + const clientName = + typeof body.client_name === "string" ? body.client_name : null; + const logoUri = typeof body.logo_uri === "string" ? body.logo_uri : null; + + const clientId = generateClientId(); + const clientSecret = generateClientSecret(); + const clientSecretHash = hashSecret(clientSecret); + const now = Math.floor(Date.now() / 1000); + + db.query( + "INSERT INTO apps (client_id, redirect_uris, name, logo_url, is_preregistered, client_secret_hash, first_seen, last_used) VALUES (?, ?, ?, ?, 1, ?, ?, ?)", + ).run( + clientId, + JSON.stringify(redirectUris), + clientName, + logoUri, + clientSecretHash, + now, + now, + ); + + const origin = process.env.ORIGIN || "http://localhost:3000"; + + // RFC 7591 §3.2.1 registration response. client_secret is returned once, + // in plaintext, only here; it is never stored or shown again. + return Response.json( + { + client_id: clientId, + client_secret: clientSecret, + client_id_issued_at: now, + client_secret_expires_at: 0, // never expires + redirect_uris: redirectUris, + client_name: clientName ?? undefined, + logo_uri: logoUri ?? undefined, + token_endpoint_auth_method: "client_secret_post", + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + issuer: origin, + }, + { status: 201, headers: NO_STORE_HEADERS }, + ); +} diff --git a/test/register.test.ts b/test/register.test.ts new file mode 100644 index 0000000..fea8a77 --- /dev/null +++ b/test/register.test.ts @@ -0,0 +1,84 @@ +import { beforeEach, describe, expect, test } from "bun:test"; +import { hashSecret } from "../src/lib/secrets"; +import { registerClient } from "../src/routes/oauth/register"; +import { db } from "./helpers/db"; + +function registerReq(body: unknown): Request { + return new Request("http://localhost/oauth/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +beforeEach(() => { + db.query("DELETE FROM apps").run(); +}); + +describe("POST /oauth/register (RFC 7591)", () => { + test("registers a client and returns id + secret once", async () => { + const res = await registerClient( + registerReq({ + redirect_uris: ["https://myapp.example.com/callback"], + client_name: "My App", + }), + ); + expect(res.status).toBe(201); + + const body = await res.json(); + expect(body.client_id).toStartWith("ikc_"); + expect(body.client_secret).toStartWith("iks_"); + expect(body.token_endpoint_auth_method).toBe("client_secret_post"); + expect(body.redirect_uris).toEqual(["https://myapp.example.com/callback"]); + expect(body.client_name).toBe("My App"); + expect(body.client_id_issued_at).toBeNumber(); + + // Secret stored hashed, not plaintext + const row = db + .query( + "SELECT client_id, client_secret_hash, is_preregistered, redirect_uris FROM apps WHERE client_id = ?", + ) + .get(body.client_id) as { + client_id: string; + client_secret_hash: string; + is_preregistered: number; + redirect_uris: string; + }; + expect(row.is_preregistered).toBe(1); + expect(row.client_secret_hash).toBe(hashSecret(body.client_secret)); + expect(row.client_secret_hash).not.toBe(body.client_secret); + }); + + test("rejects missing redirect_uris", async () => { + const res = await registerClient(registerReq({ client_name: "x" })); + expect(res.status).toBe(400); + expect((await res.json()).error).toBe("invalid_redirect_uri"); + }); + + test("rejects empty redirect_uris array", async () => { + const res = await registerClient(registerReq({ redirect_uris: [] })); + expect(res.status).toBe(400); + }); + + test("rejects a malformed redirect_uri", async () => { + const res = await registerClient( + registerReq({ redirect_uris: ["not a url"] }), + ); + expect(res.status).toBe(400); + expect((await res.json()).error).toBe("invalid_redirect_uri"); + }); + + test("rejects non-array redirect_uris", async () => { + const res = await registerClient( + registerReq({ redirect_uris: "https://x.com/cb" }), + ); + expect(res.status).toBe(400); + }); + + test("no-store cache header", async () => { + const res = await registerClient( + registerReq({ redirect_uris: ["https://x.com/cb"] }), + ); + expect(res.headers.get("Cache-Control")).toBe("no-store"); + }); +});