Skip to content

Commit 87be88e

Browse files
authored
okta: RFC 8414 path-insert discovery + RFC 7591 dynamic client registration (#10)
* okta: RFC 8414 path-insert discovery + RFC 7591 dynamic client registration - Serve authorization-server metadata at the path-insert well-known forms (/.well-known/{oauth-authorization-server,openid-configuration}/oauth2/:id) alongside the existing suffix forms; advertise registration_endpoint, S256, and token_endpoint_auth_methods_supported. - Add POST /oauth2/v1/clients and POST /oauth2/:authServerId/v1/clients implementing RFC 7591: persists clients into the shared store so the authorize/token flow accepts DCR-minted clients, public clients get no secret, RFC 6749 error envelopes (invalid_client_metadata, invalid_redirect_uri). - Wire-level tests incl. a simulated client probe -> register -> PKCE code exchange end to end, and a one-shot fault on the registration route. * Give the cold-start github api test a CI-tolerant timeout The first createEmulator test absorbs the plugin import cost; it ran at 2.9s on a green main run and deterministically exceeded the 5s default on this PR's runners (failed twice, passes locally in ~0.3s). * Wait for the ad-hoc TTL test server to listen before fetching serve() returns before the socket is bound; on slow CI runners the first fetch raced the listen and failed with ECONNREFUSED on the test's own port. Pre-existing flake surfaced by this PR's runs.
1 parent d69eb98 commit 87be88e

5 files changed

Lines changed: 446 additions & 10 deletions

File tree

packages/@emulators/okta/src/__tests__/okta.test.ts

Lines changed: 240 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@ import { createHash } from "node:crypto";
22
import { decodeJwt } from "jose";
33
import { Hono } from "@emulators/core";
44
import { beforeEach, describe, expect, it } from "vitest";
5-
import { Store, WebhookDispatcher, authMiddleware, type TokenMap } from "@emulators/core";
5+
import { Store, WebhookDispatcher, authMiddleware, createServer, type TokenMap } from "@emulators/core";
66
import { getOktaStore, oktaPlugin, seedFromConfig } from "../index.js";
7+
import { manifest } from "../manifest.js";
78

89
const base = "http://localhost:4000";
910

@@ -156,6 +157,89 @@ async function exchangeCode(
156157
});
157158
}
158159

160+
function dcrRequestBody(tokenEndpointAuthMethod: "none" | "client_secret_post" = "none") {
161+
return {
162+
redirect_uris: ["http://localhost:3000/dcr-callback"],
163+
client_name: "Executor DCR Client",
164+
grant_types: ["authorization_code", "refresh_token"],
165+
response_types: ["code"],
166+
token_endpoint_auth_method: tokenEndpointAuthMethod,
167+
scope: "openid profile email offline_access",
168+
};
169+
}
170+
171+
async function registerClient(
172+
app: Hono,
173+
registrationEndpoint = `${base}/oauth2/default/v1/clients`,
174+
tokenEndpointAuthMethod: "none" | "client_secret_post" = "none",
175+
): Promise<Record<string, unknown>> {
176+
const res = await app.request(registrationEndpoint, {
177+
method: "POST",
178+
headers: { "Content-Type": "application/json", Accept: "application/json" },
179+
body: JSON.stringify(dcrRequestBody(tokenEndpointAuthMethod)),
180+
});
181+
expect(res.status).toBe(201);
182+
return (await res.json()) as Record<string, unknown>;
183+
}
184+
185+
async function completePkceCodeFlow(
186+
app: Hono,
187+
store: Store,
188+
options: {
189+
authorizationEndpoint?: string;
190+
tokenEndpoint?: string;
191+
clientId: string;
192+
clientSecret?: string;
193+
redirectUri?: string;
194+
includeClientSecret?: boolean;
195+
},
196+
): Promise<Record<string, unknown>> {
197+
const redirectUri = options.redirectUri ?? "http://localhost:3000/dcr-callback";
198+
const verifier = "executor-pkce-verifier-12345";
199+
const challenge = createHash("sha256").update(verifier).digest("base64url");
200+
const authorizationEndpoint = options.authorizationEndpoint ?? `${base}/oauth2/default/v1/authorize`;
201+
202+
const authorizeUrl = new URL(authorizationEndpoint);
203+
authorizeUrl.searchParams.set("client_id", options.clientId);
204+
authorizeUrl.searchParams.set("redirect_uri", redirectUri);
205+
authorizeUrl.searchParams.set("response_type", "code");
206+
authorizeUrl.searchParams.set("state", "executor-state");
207+
authorizeUrl.searchParams.set("code_challenge", challenge);
208+
authorizeUrl.searchParams.set("code_challenge_method", "S256");
209+
authorizeUrl.searchParams.set("scope", "openid profile email offline_access");
210+
211+
const authorizeRes = await app.request(authorizeUrl.toString(), { headers: { Accept: "text/html" } });
212+
expect(authorizeRes.status).toBe(200);
213+
214+
const { code, state } = await getAuthCode(app, store, {
215+
authServerId: "default",
216+
clientId: options.clientId,
217+
redirectUri,
218+
scope: "openid profile email offline_access",
219+
state: "executor-state",
220+
codeChallenge: challenge,
221+
codeChallengeMethod: "S256",
222+
});
223+
expect(code).toBeTruthy();
224+
expect(state).toBe("executor-state");
225+
226+
const tokenUrl = new URL(options.tokenEndpoint ?? `${base}/oauth2/default/v1/token`);
227+
const tokenRes = await app.request(tokenUrl.toString(), {
228+
method: "POST",
229+
headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
230+
body: new URLSearchParams({
231+
grant_type: "authorization_code",
232+
code,
233+
client_id: options.clientId,
234+
...(options.includeClientSecret === false ? {} : { client_secret: options.clientSecret ?? "" }),
235+
redirect_uri: redirectUri,
236+
code_verifier: verifier,
237+
}).toString(),
238+
});
239+
expect(tokenRes.status).toBe(200);
240+
return (await tokenRes.json()) as Record<string, unknown>;
241+
}
242+
159243
describe("Okta plugin integration", () => {
160244
let app: Hono;
161245
let store: Store;
@@ -180,7 +264,7 @@ describe("Okta plugin integration", () => {
180264
expect(body.introspection_endpoint).toBe(`${base}/oauth2/v1/introspect`);
181265
expect(body.registration_endpoint).toBe(`${base}/oauth2/v1/clients`);
182266
expect(body.code_challenge_methods_supported).toEqual(["plain", "S256"]);
183-
expect(body.token_endpoint_auth_methods_supported).toEqual(["client_secret_post", "client_secret_basic", "none"]);
267+
expect(body.token_endpoint_auth_methods_supported).toEqual(["client_secret_basic", "client_secret_post", "none"]);
184268
expect(body.request_parameter_supported).toBe(false);
185269
});
186270

@@ -202,6 +286,40 @@ describe("Okta plugin integration", () => {
202286
expect(body.token_endpoint).toBe(`${base}/oauth2/custom-as/v1/token`);
203287
});
204288

289+
it("returns path-insert metadata for both well-known suffixes", async () => {
290+
for (const path of [
291+
"/.well-known/oauth-authorization-server/oauth2/default",
292+
"/.well-known/openid-configuration/oauth2/default",
293+
]) {
294+
const res = await app.request(`${base}${path}`, { headers: { Accept: "application/json" } });
295+
expect(res.status).toBe(200);
296+
const body = (await res.json()) as Record<string, unknown>;
297+
expect(body.issuer).toBe(`${base}/oauth2/default`);
298+
expect(body.authorization_endpoint).toBe(`${base}/oauth2/default/v1/authorize`);
299+
expect(body.token_endpoint).toBe(`${base}/oauth2/default/v1/token`);
300+
expect(body.registration_endpoint).toBe(`${base}/oauth2/default/v1/clients`);
301+
expect(body.response_types_supported).toContain("code");
302+
expect(body.code_challenge_methods_supported).toContain("S256");
303+
}
304+
});
305+
306+
it("returns the same auth-server metadata for suffix and path-insert aliases", async () => {
307+
const paths = [
308+
"/oauth2/default/.well-known/openid-configuration",
309+
"/oauth2/default/.well-known/oauth-authorization-server",
310+
"/.well-known/openid-configuration/oauth2/default",
311+
"/.well-known/oauth-authorization-server/oauth2/default",
312+
];
313+
const documents = await Promise.all(
314+
paths.map(async (path) => {
315+
const res = await app.request(`${base}${path}`, { headers: { Accept: "application/json" } });
316+
expect(res.status).toBe(200);
317+
return (await res.json()) as Record<string, unknown>;
318+
}),
319+
);
320+
expect(documents.every((doc) => JSON.stringify(doc) === JSON.stringify(documents[0]))).toBe(true);
321+
});
322+
205323
it("returns 404 for unknown custom auth server", async () => {
206324
const res = await app.request(`${base}/oauth2/does-not-exist/.well-known/openid-configuration`);
207325
expect(res.status).toBe(404);
@@ -346,6 +464,126 @@ describe("Okta plugin integration", () => {
346464
});
347465
});
348466

467+
describe("dynamic client registration", () => {
468+
it("registers public and confidential clients with RFC 7591 responses", async () => {
469+
const publicClient = await registerClient(app, `${base}/oauth2/default/v1/clients`, "none");
470+
expect(typeof publicClient.client_id).toBe("string");
471+
expect(publicClient.client_secret).toBeUndefined();
472+
expect(publicClient.client_id_issued_at).toEqual(expect.any(Number));
473+
expect(publicClient.client_secret_expires_at).toBe(0);
474+
expect(publicClient.client_name).toBe("Executor DCR Client");
475+
expect(publicClient.redirect_uris).toEqual(["http://localhost:3000/dcr-callback"]);
476+
expect(publicClient.grant_types).toEqual(["authorization_code", "refresh_token"]);
477+
expect(publicClient.response_types).toEqual(["code"]);
478+
expect(publicClient.token_endpoint_auth_method).toBe("none");
479+
480+
const confidentialClient = await registerClient(app, `${base}/oauth2/default/v1/clients`, "client_secret_post");
481+
expect(typeof confidentialClient.client_id).toBe("string");
482+
expect(typeof confidentialClient.client_secret).toBe("string");
483+
expect(confidentialClient.token_endpoint_auth_method).toBe("client_secret_post");
484+
});
485+
486+
it("registered public client completes authorization-code PKCE S256 exchange", async () => {
487+
const client = await registerClient(app, `${base}/oauth2/default/v1/clients`, "none");
488+
const tokenBody = await completePkceCodeFlow(app, store, {
489+
clientId: client.client_id as string,
490+
includeClientSecret: false,
491+
});
492+
expect((tokenBody.access_token as string).startsWith("okta_")).toBe(true);
493+
expect((tokenBody.refresh_token as string).startsWith("r_okta_")).toBe(true);
494+
const claims = decodeJwt(tokenBody.id_token as string);
495+
expect(claims.aud).toBe(client.client_id);
496+
expect(claims.iss).toBe(`${base}/oauth2/default`);
497+
});
498+
499+
it("rejects invalid metadata with an RFC 6749 error envelope", async () => {
500+
const res = await app.request(`${base}/oauth2/default/v1/clients`, {
501+
method: "POST",
502+
headers: { "Content-Type": "application/json", Accept: "application/json" },
503+
body: JSON.stringify({ ...dcrRequestBody(), redirect_uris: [] }),
504+
});
505+
expect(res.status).toBe(400);
506+
const body = (await res.json()) as Record<string, unknown>;
507+
expect(body).toEqual({
508+
error: "invalid_client_metadata",
509+
error_description: "redirect_uris must be a non-empty array of strings.",
510+
});
511+
});
512+
513+
it("rejects invalid redirect URIs with invalid_redirect_uri", async () => {
514+
const res = await app.request(`${base}/oauth2/default/v1/clients`, {
515+
method: "POST",
516+
headers: { "Content-Type": "application/json", Accept: "application/json" },
517+
body: JSON.stringify({ ...dcrRequestBody(), redirect_uris: ["not a url"] }),
518+
});
519+
expect(res.status).toBe(400);
520+
const body = (await res.json()) as Record<string, unknown>;
521+
expect(body.error).toBe("invalid_redirect_uri");
522+
expect(body.error_description).toBe("Each redirect_uri must be an absolute HTTP or HTTPS URL.");
523+
});
524+
525+
it("satisfies executor metadata probe then registers and completes PKCE", async () => {
526+
const metadataRes = await app.request(`${base}/.well-known/oauth-authorization-server/oauth2/default`, {
527+
headers: { Accept: "application/json" },
528+
});
529+
expect(metadataRes.status).toBe(200);
530+
const metadata = (await metadataRes.json()) as Record<string, unknown>;
531+
expect(metadata.registration_endpoint).toBe(`${base}/oauth2/default/v1/clients`);
532+
533+
const client = await registerClient(app, metadata.registration_endpoint as string, "none");
534+
const tokenBody = await completePkceCodeFlow(app, store, {
535+
authorizationEndpoint: metadata.authorization_endpoint as string,
536+
tokenEndpoint: metadata.token_endpoint as string,
537+
clientId: client.client_id as string,
538+
includeClientSecret: false,
539+
});
540+
expect(tokenBody.token_type).toBe("Bearer");
541+
expect(tokenBody.scope).toBe("openid profile email offline_access");
542+
});
543+
544+
it("fires an armed registration fault once and records it in the ledger", async () => {
545+
const server = createServer(oktaPlugin, { baseUrl: base, manifest });
546+
oktaPlugin.seed?.(server.store, base);
547+
548+
const arm = await server.app.request(`${base}/_emulate/faults`, {
549+
method: "POST",
550+
headers: { "Content-Type": "application/json" },
551+
body: JSON.stringify({
552+
match: { method: "POST", pathPattern: "/oauth2/default/v1/clients" },
553+
response: {
554+
status: 503,
555+
body: { error: "temporarily_unavailable", error_description: "Injected failure." },
556+
},
557+
}),
558+
});
559+
expect(arm.status).toBe(200);
560+
561+
const first = await server.app.request(`${base}/oauth2/default/v1/clients`, {
562+
method: "POST",
563+
headers: { "Content-Type": "application/json", Accept: "application/json" },
564+
body: JSON.stringify(dcrRequestBody()),
565+
});
566+
expect(first.status).toBe(503);
567+
expect(((await first.json()) as Record<string, unknown>).error).toBe("temporarily_unavailable");
568+
569+
const second = await server.app.request(`${base}/oauth2/default/v1/clients`, {
570+
method: "POST",
571+
headers: { "Content-Type": "application/json", Accept: "application/json" },
572+
body: JSON.stringify(dcrRequestBody()),
573+
});
574+
expect(second.status).toBe(201);
575+
576+
const ledgerRes = await server.app.request(`${base}/_emulate/ledger`);
577+
expect(ledgerRes.status).toBe(200);
578+
const ledger = (await ledgerRes.json()) as { entries: Array<Record<string, unknown>> };
579+
const faulted = ledger.entries.find(
580+
(entry) => entry.path === "/oauth2/default/v1/clients" && entry.faulted === true,
581+
);
582+
expect(faulted).toBeDefined();
583+
expect((faulted?.response as Record<string, unknown>).status).toBe(503);
584+
});
585+
});
586+
349587
describe("PKCE", () => {
350588
it("supports S256 code challenge", async () => {
351589
const verifier = "pkce-verifier-12345";

packages/@emulators/okta/src/manifest.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,14 +38,50 @@ export const manifest: ServiceManifest = {
3838
path: "/.well-known/openid-configuration",
3939
status: "hand-authored",
4040
},
41+
{
42+
operationId: "oauth/authorizationServerMetadata",
43+
method: "GET",
44+
path: "/.well-known/oauth-authorization-server",
45+
status: "hand-authored",
46+
},
4147
{
4248
operationId: "oidc/authServerDiscovery",
4349
method: "GET",
4450
path: "/oauth2/:authServerId/.well-known/openid-configuration",
4551
status: "hand-authored",
4652
},
53+
{
54+
operationId: "oauth/authServerAuthorizationServerMetadata",
55+
method: "GET",
56+
path: "/oauth2/:authServerId/.well-known/oauth-authorization-server",
57+
status: "hand-authored",
58+
},
59+
{
60+
operationId: "oidc/pathInsertAuthServerDiscovery",
61+
method: "GET",
62+
path: "/.well-known/openid-configuration/oauth2/:authServerId",
63+
status: "hand-authored",
64+
},
65+
{
66+
operationId: "oauth/pathInsertAuthServerAuthorizationServerMetadata",
67+
method: "GET",
68+
path: "/.well-known/oauth-authorization-server/oauth2/:authServerId",
69+
status: "hand-authored",
70+
},
4771
{ operationId: "oidc/jwks", method: "GET", path: "/oauth2/v1/keys", status: "hand-authored" },
4872
{ operationId: "oauth/authorize", method: "GET", path: "/oauth2/v1/authorize", status: "hand-authored" },
73+
{
74+
operationId: "oauth/registerClient",
75+
method: "POST",
76+
path: "/oauth2/v1/clients",
77+
status: "hand-authored",
78+
},
79+
{
80+
operationId: "oauth/registerAuthServerClient",
81+
method: "POST",
82+
path: "/oauth2/:authServerId/v1/clients",
83+
status: "hand-authored",
84+
},
4985
{ operationId: "oauth/token", method: "POST", path: "/oauth2/v1/token", status: "hand-authored" },
5086
{ operationId: "oauth/userinfo", method: "GET", path: "/oauth2/v1/userinfo", status: "hand-authored" },
5187
{ operationId: "oauth/introspect", method: "POST", path: "/oauth2/v1/introspect", status: "hand-authored" },

0 commit comments

Comments
 (0)