@starci/auth-oidc 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/LICENSE +21 -0
- package/README.md +73 -2
- package/dist/client/index.d.ts +5 -0
- package/dist/client/index.js +3 -0
- package/dist/client/oidc-client.d.ts +85 -0
- package/dist/client/oidc-client.js +304 -0
- package/dist/client/pkce.d.ts +15 -0
- package/dist/client/pkce.js +29 -0
- package/dist/contracts/index.d.ts +137 -0
- package/dist/contracts/index.js +55 -0
- package/dist/server/client.d.ts +48 -0
- package/dist/server/client.js +114 -0
- package/dist/server/discovery.d.ts +21 -0
- package/dist/server/discovery.js +63 -0
- package/dist/server/errors.d.ts +11 -0
- package/dist/server/errors.js +14 -0
- package/dist/server/index.d.ts +22 -0
- package/dist/server/index.js +12 -0
- package/dist/server/jwks.d.ts +22 -0
- package/dist/server/jwks.js +80 -0
- package/dist/server/oidc.d.ts +23 -0
- package/dist/server/oidc.js +21 -0
- package/dist/server/oidc.module.d.ts +33 -0
- package/dist/server/oidc.module.js +47 -0
- package/dist/server/oidc.tokens.d.ts +12 -0
- package/dist/server/oidc.tokens.js +12 -0
- package/dist/server/options.d.ts +113 -0
- package/dist/server/options.js +91 -0
- package/dist/server/primitives.d.ts +22 -0
- package/dist/server/primitives.js +17 -0
- package/dist/server/roles.d.ts +12 -0
- package/dist/server/roles.js +51 -0
- package/dist/server/tokens.d.ts +24 -0
- package/dist/server/tokens.js +79 -0
- package/dist/server/verifier.d.ts +15 -0
- package/dist/server/verifier.js +58 -0
- package/dist/testing/index.d.ts +142 -0
- package/dist/testing/index.js +354 -0
- package/package.json +104 -4
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import type { JWK } from "jose";
|
|
2
|
+
import type { OidcHttp, OidcHttpRequest, OidcHttpResponse, OidcOptions } from "../server/options.js";
|
|
3
|
+
import type { OidcClock } from "../server/primitives.js";
|
|
4
|
+
/** A clock the test moves by hand. */
|
|
5
|
+
export declare class FakeClock implements OidcClock {
|
|
6
|
+
#private;
|
|
7
|
+
constructor(start?: Date | number);
|
|
8
|
+
now(): Date;
|
|
9
|
+
/** Moves time forward. */
|
|
10
|
+
advance(milliseconds: number): void;
|
|
11
|
+
}
|
|
12
|
+
/** The claims of a token the fake issuer mints. */
|
|
13
|
+
export interface FakeTokenClaims {
|
|
14
|
+
/** The subject (`user-1`). */
|
|
15
|
+
readonly sub?: string | undefined;
|
|
16
|
+
readonly email?: string | undefined;
|
|
17
|
+
readonly name?: string | undefined;
|
|
18
|
+
readonly username?: string | undefined;
|
|
19
|
+
/** The realm roles (`realm_access.roles`). */
|
|
20
|
+
readonly roles?: readonly string[] | undefined;
|
|
21
|
+
/** The client roles (`resource_access.<client>.roles`). */
|
|
22
|
+
readonly clientRoles?: Readonly<Record<string, readonly string[]>> | undefined;
|
|
23
|
+
/** The provider session (`sid`); one is generated when absent. */
|
|
24
|
+
readonly sessionId?: string | undefined;
|
|
25
|
+
/** Further claims, copied as they are. */
|
|
26
|
+
readonly extra?: Readonly<Record<string, unknown>> | undefined;
|
|
27
|
+
}
|
|
28
|
+
/** What a spec bends in one token to prove a refusal. */
|
|
29
|
+
export interface MintOverrides {
|
|
30
|
+
/** The `iss` claim (the issuer's own when absent). */
|
|
31
|
+
readonly issuer?: string | undefined;
|
|
32
|
+
/** The `aud` claim (the issuer's `audience`, else its client id). */
|
|
33
|
+
readonly audience?: string | readonly string[] | undefined;
|
|
34
|
+
/** Seconds the token lives from `issuedAt` (the issuer's access TTL). Negative mints an expired token. */
|
|
35
|
+
readonly expiresInSeconds?: number | undefined;
|
|
36
|
+
/** The `iat` instant (the clock's now). */
|
|
37
|
+
readonly issuedAt?: Date | undefined;
|
|
38
|
+
/** The `nbf` instant. */
|
|
39
|
+
readonly notBefore?: Date | undefined;
|
|
40
|
+
/** The `kid` header: a text, or `null` to omit it (the signing key's id when absent). */
|
|
41
|
+
readonly kid?: string | null | undefined;
|
|
42
|
+
/** `foreign` signs with a key the issuer does not publish. */
|
|
43
|
+
readonly signer?: "current" | "foreign" | undefined;
|
|
44
|
+
/** The `typ` claim (`Bearer` for an access token, `ID` for an ID token). */
|
|
45
|
+
readonly type?: string | undefined;
|
|
46
|
+
}
|
|
47
|
+
/** The options of a fake issuer. */
|
|
48
|
+
export interface FakeOidcIssuerOptions {
|
|
49
|
+
/** The realm segment of the issuer address (`test`). */
|
|
50
|
+
readonly realm?: string | undefined;
|
|
51
|
+
/** The origin of an in-memory issuer (`https://idp.test`); ignored with `listen`. */
|
|
52
|
+
readonly origin?: string | undefined;
|
|
53
|
+
/** The registered client id (`test-client`). */
|
|
54
|
+
readonly clientId?: string | undefined;
|
|
55
|
+
/** The client secret the token endpoint requires; none makes the client public. */
|
|
56
|
+
readonly clientSecret?: string | undefined;
|
|
57
|
+
/** The audience of access tokens (the client id). */
|
|
58
|
+
readonly audience?: string | undefined;
|
|
59
|
+
/** The signature algorithm (`RS256`). */
|
|
60
|
+
readonly algorithm?: "RS256" | "ES256" | undefined;
|
|
61
|
+
/** The clock token times are read from (the system clock). */
|
|
62
|
+
readonly clock?: OidcClock | undefined;
|
|
63
|
+
/** How long an access token lives, in seconds (300). */
|
|
64
|
+
readonly accessTtlSeconds?: number | undefined;
|
|
65
|
+
/** Serves the issuer over a real HTTP socket on 127.0.0.1, for a browser or a process that cannot use the in-memory port. */
|
|
66
|
+
readonly listen?: boolean | undefined;
|
|
67
|
+
}
|
|
68
|
+
/** What a failure injection does to the next requests. */
|
|
69
|
+
export type FakeFailure = {
|
|
70
|
+
readonly status: number;
|
|
71
|
+
} | "network";
|
|
72
|
+
/** A recorded request, for a spec to count what the code under test asked. */
|
|
73
|
+
export interface FakeRequest {
|
|
74
|
+
readonly method: string;
|
|
75
|
+
readonly path: string;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* A fake OIDC provider that behaves like Keycloak for what the package uses: the discovery document, the JWKS, the token endpoint (authorization
|
|
79
|
+
* code with PKCE, refresh token with rotation) and the logout endpoint, with keys that rotate and tokens a spec can bend (expired, wrong
|
|
80
|
+
* audience, wrong issuer, unknown key, foreign signature). Its `http` is an in-memory `OidcHttp` (no socket, no port), so an app's spec registers
|
|
81
|
+
* the module against it and needs no Keycloak; `listen: true` serves the same issuer on a real socket for a browser.
|
|
82
|
+
*/
|
|
83
|
+
export declare class FakeOidcIssuer {
|
|
84
|
+
#private;
|
|
85
|
+
readonly clientId: string;
|
|
86
|
+
private constructor();
|
|
87
|
+
/** Creates an issuer with one signing key, and starts its socket when `listen` is set. */
|
|
88
|
+
static create(options?: FakeOidcIssuerOptions): Promise<FakeOidcIssuer>;
|
|
89
|
+
/** The exact issuer: the `iss` claim and the `issuer` of the discovery document. */
|
|
90
|
+
get issuer(): string;
|
|
91
|
+
/** The in-memory outbound port: answers like the issuer's endpoints, with the failures the spec injected. */
|
|
92
|
+
get http(): OidcHttp;
|
|
93
|
+
/**
|
|
94
|
+
* Moves the issuer to another origin (`https://127.0.0.1:41001`), for a host that serves it on an address it only knows after it listens, as a
|
|
95
|
+
* test-world fake does. Tokens minted afterwards carry the new `iss` and the discovery document names the new endpoints; the keys stay.
|
|
96
|
+
*/
|
|
97
|
+
rebase(origin: string): void;
|
|
98
|
+
/** Answers one request as the issuer's endpoints do (the request's URL is under the issuer's address); a host that serves the issuer itself calls this from its listener. */
|
|
99
|
+
serve(request: OidcHttpRequest): Promise<OidcHttpResponse>;
|
|
100
|
+
/** The same endpoints as a `fetch`, for a front-end client under test (the browser client takes a `fetch`, not an `OidcHttp`). */
|
|
101
|
+
get fetch(): typeof fetch;
|
|
102
|
+
/** The requests the issuer has answered, oldest first. */
|
|
103
|
+
get requests(): readonly FakeRequest[];
|
|
104
|
+
/** How many requests asked this path (`/protocol/openid-connect/certs`, `/.well-known/openid-configuration`). */
|
|
105
|
+
count(path: string): number;
|
|
106
|
+
/** The options a registration against this issuer takes: the issuer, the audience, the in-memory port, the clock, and the client when `callbackUri` is given. */
|
|
107
|
+
options(overrides?: Partial<OidcOptions> & {
|
|
108
|
+
readonly callbackUri?: string;
|
|
109
|
+
readonly postLogoutUri?: string;
|
|
110
|
+
}): OidcOptions;
|
|
111
|
+
/** The discovery document. */
|
|
112
|
+
metadata(): Readonly<Record<string, unknown>>;
|
|
113
|
+
/** The published key set: every key of the ring. */
|
|
114
|
+
jwks(): {
|
|
115
|
+
readonly keys: readonly JWK[];
|
|
116
|
+
};
|
|
117
|
+
/** The `kid` of the key that signs now. */
|
|
118
|
+
get keyId(): string;
|
|
119
|
+
/** Rotates the signing key: a new key signs from now on. The old keys stay published (so tokens already minted verify) unless `keepOld` is false. Answers the new `kid`. */
|
|
120
|
+
rotateKeys(options?: {
|
|
121
|
+
readonly keepOld?: boolean;
|
|
122
|
+
}): Promise<string>;
|
|
123
|
+
/** Mints an access token for the claims, signed by the current key unless an override bends it. */
|
|
124
|
+
mintAccessToken(claims?: FakeTokenClaims, overrides?: MintOverrides): Promise<string>;
|
|
125
|
+
/** Mints an ID token for the client, carrying the nonce of the transaction. */
|
|
126
|
+
mintIdToken(claims?: FakeTokenClaims, nonce?: string, overrides?: MintOverrides): Promise<string>;
|
|
127
|
+
/**
|
|
128
|
+
* Plays the browser of a code flow: reads the authorization address the code under test built, registers a one-use code for the claims, and
|
|
129
|
+
* answers the address the provider would redirect the browser to (the redirect address with `code`, `state` and `iss`).
|
|
130
|
+
*/
|
|
131
|
+
authorize(authorizationUrl: string, claims?: FakeTokenClaims): string;
|
|
132
|
+
/** Makes the next `count` requests fail: with an HTTP status, or as a lost connection. */
|
|
133
|
+
failNext(failure?: FakeFailure, count?: number): void;
|
|
134
|
+
/** Bends every ID token the token endpoint mints from now on (an empty object restores them). */
|
|
135
|
+
bendIdTokens(overrides?: MintOverrides): void;
|
|
136
|
+
/** Ends every refresh token the issuer holds, as an administrator signing the person out of the provider would: the next refresh answers `invalid_grant`. */
|
|
137
|
+
revokeRefreshTokens(): void;
|
|
138
|
+
/** While down, every request fails as a lost connection (an outage); `false` brings the issuer back. */
|
|
139
|
+
setDown(down: boolean): void;
|
|
140
|
+
/** Stops the socket, when there is one. */
|
|
141
|
+
close(): Promise<void>;
|
|
142
|
+
}
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import { SignJWT, calculateJwkThumbprint, exportJWK, generateKeyPair } from "jose";
|
|
4
|
+
import { OIDC_CLAIMS } from "../contracts/index.js";
|
|
5
|
+
/** A clock the test moves by hand. */
|
|
6
|
+
export class FakeClock {
|
|
7
|
+
#at;
|
|
8
|
+
constructor(start = Date.UTC(2026, 0, 1)) {
|
|
9
|
+
this.#at = typeof start === "number" ? start : start.getTime();
|
|
10
|
+
}
|
|
11
|
+
now() {
|
|
12
|
+
return new Date(this.#at);
|
|
13
|
+
}
|
|
14
|
+
/** Moves time forward. */
|
|
15
|
+
advance(milliseconds) {
|
|
16
|
+
this.#at += milliseconds;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
const KEYCLOAK_PATHS = {
|
|
20
|
+
authorization: "/protocol/openid-connect/auth",
|
|
21
|
+
token: "/protocol/openid-connect/token",
|
|
22
|
+
certs: "/protocol/openid-connect/certs",
|
|
23
|
+
logout: "/protocol/openid-connect/logout",
|
|
24
|
+
};
|
|
25
|
+
const DISCOVERY = "/.well-known/openid-configuration";
|
|
26
|
+
const SECOND_MS = 1000;
|
|
27
|
+
const s256 = (verifier) => createHash("sha256").update(verifier).digest("base64url");
|
|
28
|
+
const generateKey = async (algorithm) => {
|
|
29
|
+
const pair = await generateKeyPair(algorithm, { extractable: true });
|
|
30
|
+
const exported = await exportJWK(pair.publicKey);
|
|
31
|
+
const kid = await calculateJwkThumbprint(exported);
|
|
32
|
+
return { kid, privateKey: pair.privateKey, jwk: { ...exported, kid, alg: algorithm, use: "sig" } };
|
|
33
|
+
};
|
|
34
|
+
const form = (value) => Object.fromEntries(new URLSearchParams(value));
|
|
35
|
+
const readBody = async (request) => {
|
|
36
|
+
const chunks = [];
|
|
37
|
+
for await (const chunk of request)
|
|
38
|
+
chunks.push(Buffer.from(chunk));
|
|
39
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* A fake OIDC provider that behaves like Keycloak for what the package uses: the discovery document, the JWKS, the token endpoint (authorization
|
|
43
|
+
* code with PKCE, refresh token with rotation) and the logout endpoint, with keys that rotate and tokens a spec can bend (expired, wrong
|
|
44
|
+
* audience, wrong issuer, unknown key, foreign signature). Its `http` is an in-memory `OidcHttp` (no socket, no port), so an app's spec registers
|
|
45
|
+
* the module against it and needs no Keycloak; `listen: true` serves the same issuer on a real socket for a browser.
|
|
46
|
+
*/
|
|
47
|
+
export class FakeOidcIssuer {
|
|
48
|
+
clientId;
|
|
49
|
+
#realm;
|
|
50
|
+
#algorithm;
|
|
51
|
+
#audience;
|
|
52
|
+
#secret;
|
|
53
|
+
#clock;
|
|
54
|
+
#accessTtl;
|
|
55
|
+
#ring = [];
|
|
56
|
+
#codes = new Map();
|
|
57
|
+
#refreshTokens = new Map();
|
|
58
|
+
#failures = [];
|
|
59
|
+
#requests = [];
|
|
60
|
+
#issuer;
|
|
61
|
+
#server;
|
|
62
|
+
#down = false;
|
|
63
|
+
#idOverrides = {};
|
|
64
|
+
constructor(options, issuer) {
|
|
65
|
+
this.clientId = options.clientId ?? "test-client";
|
|
66
|
+
this.#realm = options.realm ?? "test";
|
|
67
|
+
this.#algorithm = options.algorithm ?? "RS256";
|
|
68
|
+
this.#audience = options.audience ?? this.clientId;
|
|
69
|
+
this.#secret = options.clientSecret;
|
|
70
|
+
this.#clock = options.clock ?? { now: () => new Date() };
|
|
71
|
+
this.#accessTtl = options.accessTtlSeconds ?? 300;
|
|
72
|
+
this.#issuer = issuer;
|
|
73
|
+
}
|
|
74
|
+
/** Creates an issuer with one signing key, and starts its socket when `listen` is set. */
|
|
75
|
+
static async create(options = {}) {
|
|
76
|
+
const realm = options.realm ?? "test";
|
|
77
|
+
const issuer = new FakeOidcIssuer(options, `${options.origin ?? "https://idp.test"}/realms/${realm}`);
|
|
78
|
+
issuer.#ring.push(await generateKey(issuer.#algorithm));
|
|
79
|
+
if (options.listen === true)
|
|
80
|
+
await issuer.#start();
|
|
81
|
+
return issuer;
|
|
82
|
+
}
|
|
83
|
+
/** The exact issuer: the `iss` claim and the `issuer` of the discovery document. */
|
|
84
|
+
get issuer() {
|
|
85
|
+
return this.#issuer;
|
|
86
|
+
}
|
|
87
|
+
/** The in-memory outbound port: answers like the issuer's endpoints, with the failures the spec injected. */
|
|
88
|
+
get http() {
|
|
89
|
+
return { request: (request) => this.#serve(request) };
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Moves the issuer to another origin (`https://127.0.0.1:41001`), for a host that serves it on an address it only knows after it listens, as a
|
|
93
|
+
* test-world fake does. Tokens minted afterwards carry the new `iss` and the discovery document names the new endpoints; the keys stay.
|
|
94
|
+
*/
|
|
95
|
+
rebase(origin) {
|
|
96
|
+
this.#issuer = `${origin}/realms/${this.#realm}`;
|
|
97
|
+
}
|
|
98
|
+
/** Answers one request as the issuer's endpoints do (the request's URL is under the issuer's address); a host that serves the issuer itself calls this from its listener. */
|
|
99
|
+
serve(request) {
|
|
100
|
+
return this.#serve(request);
|
|
101
|
+
}
|
|
102
|
+
/** The same endpoints as a `fetch`, for a front-end client under test (the browser client takes a `fetch`, not an `OidcHttp`). */
|
|
103
|
+
get fetch() {
|
|
104
|
+
return async (input, init) => {
|
|
105
|
+
const method = init?.method === "POST" ? "POST" : "GET";
|
|
106
|
+
const text = typeof init?.body === "string" ? init.body : "";
|
|
107
|
+
const answer = await this.#serve({ method, url: typeof input === "string" ? input : input instanceof URL ? input.href : input.url, timeoutMs: 1000, ...(text === "" ? {} : { form: form(text) }) });
|
|
108
|
+
return new Response(JSON.stringify(answer.body), { status: answer.status, headers: { "content-type": "application/json" } });
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
/** The requests the issuer has answered, oldest first. */
|
|
112
|
+
get requests() {
|
|
113
|
+
return this.#requests;
|
|
114
|
+
}
|
|
115
|
+
/** How many requests asked this path (`/protocol/openid-connect/certs`, `/.well-known/openid-configuration`). */
|
|
116
|
+
count(path) {
|
|
117
|
+
return this.#requests.filter((request) => request.path === path).length;
|
|
118
|
+
}
|
|
119
|
+
/** The options a registration against this issuer takes: the issuer, the audience, the in-memory port, the clock, and the client when `callbackUri` is given. */
|
|
120
|
+
options(overrides = {}) {
|
|
121
|
+
const { callbackUri, postLogoutUri, ...rest } = overrides;
|
|
122
|
+
return {
|
|
123
|
+
issuer: this.#issuer,
|
|
124
|
+
audience: this.#audience,
|
|
125
|
+
http: this.http,
|
|
126
|
+
clock: this.#clock,
|
|
127
|
+
allowInsecureHttp: this.#issuer.startsWith("http:"),
|
|
128
|
+
...(callbackUri === undefined
|
|
129
|
+
? {}
|
|
130
|
+
: {
|
|
131
|
+
client: {
|
|
132
|
+
clientId: this.clientId,
|
|
133
|
+
...(this.#secret === undefined ? {} : { clientSecret: this.#secret }),
|
|
134
|
+
callbackUri,
|
|
135
|
+
postLogoutUri: postLogoutUri ?? new URL("/signed-out", callbackUri).toString(),
|
|
136
|
+
},
|
|
137
|
+
}),
|
|
138
|
+
...rest,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
/** The discovery document. */
|
|
142
|
+
metadata() {
|
|
143
|
+
const base = `${this.#issuer}/protocol/openid-connect`;
|
|
144
|
+
return {
|
|
145
|
+
issuer: this.#issuer,
|
|
146
|
+
authorization_endpoint: `${base}/auth`,
|
|
147
|
+
token_endpoint: `${base}/token`,
|
|
148
|
+
jwks_uri: `${base}/certs`,
|
|
149
|
+
end_session_endpoint: `${base}/logout`,
|
|
150
|
+
id_token_signing_alg_values_supported: [this.#algorithm],
|
|
151
|
+
response_types_supported: ["code"],
|
|
152
|
+
code_challenge_methods_supported: ["S256"],
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
/** The published key set: every key of the ring. */
|
|
156
|
+
jwks() {
|
|
157
|
+
return { keys: this.#ring.map((key) => key.jwk) };
|
|
158
|
+
}
|
|
159
|
+
/** The `kid` of the key that signs now. */
|
|
160
|
+
get keyId() {
|
|
161
|
+
return this.#signer().kid;
|
|
162
|
+
}
|
|
163
|
+
/** Rotates the signing key: a new key signs from now on. The old keys stay published (so tokens already minted verify) unless `keepOld` is false. Answers the new `kid`. */
|
|
164
|
+
async rotateKeys(options = {}) {
|
|
165
|
+
const fresh = await generateKey(this.#algorithm);
|
|
166
|
+
if (options.keepOld === false)
|
|
167
|
+
this.#ring.length = 0;
|
|
168
|
+
this.#ring.unshift(fresh);
|
|
169
|
+
return fresh.kid;
|
|
170
|
+
}
|
|
171
|
+
/** Mints an access token for the claims, signed by the current key unless an override bends it. */
|
|
172
|
+
mintAccessToken(claims = {}, overrides = {}) {
|
|
173
|
+
return this.#mint(claims, { type: "Bearer", ...overrides }, {});
|
|
174
|
+
}
|
|
175
|
+
/** Mints an ID token for the client, carrying the nonce of the transaction. */
|
|
176
|
+
mintIdToken(claims = {}, nonce, overrides = {}) {
|
|
177
|
+
return this.#mint(claims, { type: "ID", audience: this.clientId, ...overrides }, nonce === undefined ? {} : { nonce });
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Plays the browser of a code flow: reads the authorization address the code under test built, registers a one-use code for the claims, and
|
|
181
|
+
* answers the address the provider would redirect the browser to (the redirect address with `code`, `state` and `iss`).
|
|
182
|
+
*/
|
|
183
|
+
authorize(authorizationUrl, claims = {}) {
|
|
184
|
+
const query = new URL(authorizationUrl).searchParams;
|
|
185
|
+
const redirectUri = query.get("redirect_uri");
|
|
186
|
+
const state = query.get("state");
|
|
187
|
+
if (redirectUri === null || state === null || query.get("client_id") !== this.clientId || query.get("code_challenge_method") !== "S256") {
|
|
188
|
+
throw new Error("fake OIDC issuer: the authorization request is not a registered S256 code flow");
|
|
189
|
+
}
|
|
190
|
+
const code = randomUUID();
|
|
191
|
+
this.#codes.set(code, { claims, nonce: query.get("nonce") ?? "", challenge: query.get("code_challenge") ?? "", redirectUri, used: false });
|
|
192
|
+
const back = new URL(redirectUri);
|
|
193
|
+
back.search = new URLSearchParams({ code, state, iss: this.#issuer }).toString();
|
|
194
|
+
return back.toString();
|
|
195
|
+
}
|
|
196
|
+
/** Makes the next `count` requests fail: with an HTTP status, or as a lost connection. */
|
|
197
|
+
failNext(failure = { status: 503 }, count = 1) {
|
|
198
|
+
for (let n = 0; n < count; n += 1)
|
|
199
|
+
this.#failures.push(failure);
|
|
200
|
+
}
|
|
201
|
+
/** Bends every ID token the token endpoint mints from now on (an empty object restores them). */
|
|
202
|
+
bendIdTokens(overrides = {}) {
|
|
203
|
+
this.#idOverrides = overrides;
|
|
204
|
+
}
|
|
205
|
+
/** Ends every refresh token the issuer holds, as an administrator signing the person out of the provider would: the next refresh answers `invalid_grant`. */
|
|
206
|
+
revokeRefreshTokens() {
|
|
207
|
+
this.#refreshTokens.clear();
|
|
208
|
+
}
|
|
209
|
+
/** While down, every request fails as a lost connection (an outage); `false` brings the issuer back. */
|
|
210
|
+
setDown(down) {
|
|
211
|
+
this.#down = down;
|
|
212
|
+
}
|
|
213
|
+
/** Stops the socket, when there is one. */
|
|
214
|
+
async close() {
|
|
215
|
+
const server = this.#server;
|
|
216
|
+
if (server === undefined)
|
|
217
|
+
return;
|
|
218
|
+
this.#server = undefined;
|
|
219
|
+
server.closeAllConnections();
|
|
220
|
+
await new Promise((resolve) => {
|
|
221
|
+
server.close(() => {
|
|
222
|
+
resolve();
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
#signer() {
|
|
227
|
+
const [key] = this.#ring;
|
|
228
|
+
if (key === undefined)
|
|
229
|
+
throw new Error("fake OIDC issuer: no signing key");
|
|
230
|
+
return key;
|
|
231
|
+
}
|
|
232
|
+
async #mint(claims, overrides, extra) {
|
|
233
|
+
const signer = overrides.signer === "foreign" ? await generateKey(this.#algorithm) : this.#signer();
|
|
234
|
+
const issuedAt = Math.floor((overrides.issuedAt ?? this.#clock.now()).getTime() / SECOND_MS);
|
|
235
|
+
const subject = claims.sub ?? "user-1";
|
|
236
|
+
const realm = claims.roles === undefined ? {} : { [OIDC_CLAIMS.realmAccess]: { roles: claims.roles } };
|
|
237
|
+
const clients = claims.clientRoles === undefined
|
|
238
|
+
? {}
|
|
239
|
+
: { [OIDC_CLAIMS.resourceAccess]: Object.fromEntries(Object.entries(claims.clientRoles).map(([client, roles]) => [client, { roles }])) };
|
|
240
|
+
const jwt = new SignJWT({
|
|
241
|
+
...realm,
|
|
242
|
+
...clients,
|
|
243
|
+
[OIDC_CLAIMS.sessionId]: claims.sessionId ?? `sid-${subject}`,
|
|
244
|
+
[OIDC_CLAIMS.authorizedParty]: this.clientId,
|
|
245
|
+
...(overrides.type === undefined ? {} : { [OIDC_CLAIMS.tokenType]: overrides.type }),
|
|
246
|
+
...(claims.email === undefined ? {} : { [OIDC_CLAIMS.email]: claims.email }),
|
|
247
|
+
...(claims.name === undefined ? {} : { [OIDC_CLAIMS.name]: claims.name }),
|
|
248
|
+
...(claims.username === undefined ? {} : { [OIDC_CLAIMS.username]: claims.username }),
|
|
249
|
+
...claims.extra,
|
|
250
|
+
...extra,
|
|
251
|
+
})
|
|
252
|
+
.setProtectedHeader({ alg: this.#algorithm, typ: "JWT", ...(overrides.kid === null ? {} : { kid: overrides.kid ?? signer.kid }) })
|
|
253
|
+
.setSubject(subject)
|
|
254
|
+
.setIssuer(overrides.issuer ?? this.#issuer)
|
|
255
|
+
.setAudience(overrides.audience === undefined ? this.#audience : typeof overrides.audience === "string" ? overrides.audience : [...overrides.audience])
|
|
256
|
+
.setJti(randomUUID())
|
|
257
|
+
.setIssuedAt(issuedAt)
|
|
258
|
+
.setExpirationTime(issuedAt + (overrides.expiresInSeconds ?? this.#accessTtl));
|
|
259
|
+
if (overrides.notBefore !== undefined)
|
|
260
|
+
jwt.setNotBefore(Math.floor(overrides.notBefore.getTime() / SECOND_MS));
|
|
261
|
+
return jwt.sign(signer.privateKey);
|
|
262
|
+
}
|
|
263
|
+
async #serve(request) {
|
|
264
|
+
const url = new URL(request.url);
|
|
265
|
+
const path = url.pathname.startsWith(new URL(this.#issuer).pathname) ? url.pathname.slice(new URL(this.#issuer).pathname.length) : url.pathname;
|
|
266
|
+
this.#requests.push({ method: request.method, path });
|
|
267
|
+
if (this.#down)
|
|
268
|
+
throw new Error("fake OIDC issuer: connection refused");
|
|
269
|
+
const failure = this.#failures.shift();
|
|
270
|
+
if (failure === "network")
|
|
271
|
+
throw new Error("fake OIDC issuer: connection reset");
|
|
272
|
+
if (failure !== undefined)
|
|
273
|
+
return { status: failure.status, body: { error: "server_error" } };
|
|
274
|
+
return this.#handle(request.method, path, request.form ?? {});
|
|
275
|
+
}
|
|
276
|
+
async #handle(method, path, params) {
|
|
277
|
+
if (method === "GET" && path === DISCOVERY)
|
|
278
|
+
return { status: 200, body: this.metadata() };
|
|
279
|
+
if (method === "GET" && path === KEYCLOAK_PATHS.certs)
|
|
280
|
+
return { status: 200, body: this.jwks() };
|
|
281
|
+
if (method === "GET" && path === KEYCLOAK_PATHS.logout)
|
|
282
|
+
return { status: 200, body: {} };
|
|
283
|
+
if (method === "POST" && path === KEYCLOAK_PATHS.token)
|
|
284
|
+
return this.#token(params);
|
|
285
|
+
return { status: 404, body: { error: "not_found" } };
|
|
286
|
+
}
|
|
287
|
+
async #token(params) {
|
|
288
|
+
if (params.client_id !== this.clientId || (this.#secret !== undefined && params.client_secret !== this.#secret))
|
|
289
|
+
return { status: 401, body: { error: "invalid_client" } };
|
|
290
|
+
if (params.grant_type === "authorization_code")
|
|
291
|
+
return this.#exchangeCode(params);
|
|
292
|
+
if (params.grant_type === "refresh_token")
|
|
293
|
+
return this.#refresh(params.refresh_token);
|
|
294
|
+
return { status: 400, body: { error: "unsupported_grant_type" } };
|
|
295
|
+
}
|
|
296
|
+
async #exchangeCode(params) {
|
|
297
|
+
const pending = this.#codes.get(params.code ?? "");
|
|
298
|
+
const verifier = params.code_verifier ?? "";
|
|
299
|
+
if (pending === undefined || pending.used || pending.redirectUri !== params.redirect_uri || s256(verifier) !== pending.challenge) {
|
|
300
|
+
return { status: 400, body: { error: "invalid_grant" } };
|
|
301
|
+
}
|
|
302
|
+
pending.used = true;
|
|
303
|
+
return { status: 200, body: await this.#grant(pending.claims, pending.nonce) };
|
|
304
|
+
}
|
|
305
|
+
async #refresh(token) {
|
|
306
|
+
const held = token === undefined ? undefined : this.#refreshTokens.get(token);
|
|
307
|
+
if (held === undefined || held.used)
|
|
308
|
+
return { status: 400, body: { error: "invalid_grant" } };
|
|
309
|
+
held.used = true;
|
|
310
|
+
return { status: 200, body: await this.#grant(held.claims, undefined) };
|
|
311
|
+
}
|
|
312
|
+
async #grant(claims, nonce) {
|
|
313
|
+
const refreshToken = randomUUID();
|
|
314
|
+
this.#refreshTokens.set(refreshToken, { claims, used: false });
|
|
315
|
+
return {
|
|
316
|
+
token_type: "Bearer",
|
|
317
|
+
access_token: await this.mintAccessToken(claims),
|
|
318
|
+
id_token: await this.mintIdToken(claims, nonce, this.#idOverrides),
|
|
319
|
+
refresh_token: refreshToken,
|
|
320
|
+
expires_in: this.#accessTtl,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
async #start() {
|
|
324
|
+
const server = createServer((request, response) => {
|
|
325
|
+
void this.#respond(request, response);
|
|
326
|
+
});
|
|
327
|
+
this.#server = server;
|
|
328
|
+
await new Promise((resolve) => {
|
|
329
|
+
server.listen(0, "127.0.0.1", resolve);
|
|
330
|
+
});
|
|
331
|
+
const { port } = server.address();
|
|
332
|
+
this.#issuer = `http://127.0.0.1:${String(port)}/realms/${this.#realm}`;
|
|
333
|
+
}
|
|
334
|
+
async #respond(request, response) {
|
|
335
|
+
const cors = { "access-control-allow-origin": "*", "access-control-allow-headers": "content-type", "access-control-allow-methods": "GET, POST, OPTIONS" };
|
|
336
|
+
if (request.method === "OPTIONS") {
|
|
337
|
+
response.writeHead(204, cors).end();
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
const text = request.method === "POST" ? await readBody(request) : "";
|
|
341
|
+
try {
|
|
342
|
+
const answer = await this.#serve({
|
|
343
|
+
method: request.method === "POST" ? "POST" : "GET",
|
|
344
|
+
url: `${this.#issuer.slice(0, this.#issuer.indexOf("/realms/"))}${request.url ?? "/"}`,
|
|
345
|
+
timeoutMs: 1000,
|
|
346
|
+
...(text === "" ? {} : { form: form(text) }),
|
|
347
|
+
});
|
|
348
|
+
response.writeHead(answer.status, { ...cors, "content-type": "application/json" }).end(JSON.stringify(answer.body));
|
|
349
|
+
}
|
|
350
|
+
catch {
|
|
351
|
+
response.destroy();
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,106 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@starci/auth-oidc",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "The OIDC client of a StarCi full app (Keycloak) behind the same PrincipalVerifier interface as @starci/auth: a NestJS module (issuer discovery, JWKS with caching and rotation, algorithm-pinned token checks, role mapping, the authorization code flow with PKCE and nonce), a front-end client (PKCE redirect, callback, one shared refresh, in memory token) and a fake OIDC issuer for tests.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"auth",
|
|
7
|
+
"oidc",
|
|
8
|
+
"openid-connect",
|
|
9
|
+
"keycloak",
|
|
10
|
+
"jwks",
|
|
11
|
+
"pkce",
|
|
12
|
+
"jwt",
|
|
13
|
+
"nestjs"
|
|
14
|
+
],
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"author": "starci183",
|
|
17
|
+
"homepage": "https://github.com/starci183/starci-harness/tree/main/packages/auth-oidc#readme",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/starci183/starci-harness.git",
|
|
21
|
+
"directory": "packages/auth-oidc"
|
|
22
|
+
},
|
|
23
|
+
"bugs": {
|
|
24
|
+
"url": "https://github.com/starci183/starci-harness/issues"
|
|
25
|
+
},
|
|
26
|
+
"private": false,
|
|
27
|
+
"type": "module",
|
|
28
|
+
"main": "./dist/contracts/index.js",
|
|
29
|
+
"types": "./dist/contracts/index.d.ts",
|
|
30
|
+
"exports": {
|
|
31
|
+
"./server": {
|
|
32
|
+
"types": "./dist/server/index.d.ts",
|
|
33
|
+
"default": "./dist/server/index.js"
|
|
34
|
+
},
|
|
35
|
+
"./client": {
|
|
36
|
+
"types": "./dist/client/index.d.ts",
|
|
37
|
+
"default": "./dist/client/index.js"
|
|
38
|
+
},
|
|
39
|
+
"./contracts": {
|
|
40
|
+
"types": "./dist/contracts/index.d.ts",
|
|
41
|
+
"default": "./dist/contracts/index.js"
|
|
42
|
+
},
|
|
43
|
+
"./testing": {
|
|
44
|
+
"types": "./dist/testing/index.d.ts",
|
|
45
|
+
"default": "./dist/testing/index.js"
|
|
46
|
+
},
|
|
47
|
+
"./package.json": "./package.json"
|
|
48
|
+
},
|
|
49
|
+
"files": [
|
|
50
|
+
"dist",
|
|
51
|
+
"!dist/**/*.spec.*",
|
|
52
|
+
"LICENSE",
|
|
53
|
+
"README.md",
|
|
54
|
+
"CHANGELOG.md"
|
|
55
|
+
],
|
|
56
|
+
"sideEffects": false,
|
|
57
|
+
"engines": {
|
|
58
|
+
"node": ">=20"
|
|
59
|
+
},
|
|
60
|
+
"scripts": {
|
|
61
|
+
"build": "node -e \"require('node:fs').rmSync('dist', {recursive: true, force: true})\" && tsc -p tsconfig.build.json && node -e \"const fs = require('node:fs'); for (const f of fs.readdirSync('dist', {recursive: true}).filter((n) => n.endsWith('.d.ts'))) fs.writeFileSync('dist/' + f, fs.readFileSync('dist/' + f, 'utf8').replace(/((?:from |import\\()['\\x22]\\.\\.?\\/[^'\\x22]+)\\.ts(['\\x22])/g, (all, head, quote) => head + '.js' + quote))\"",
|
|
62
|
+
"typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.build.json --noEmit --isolatedDeclarations",
|
|
63
|
+
"lint": "eslint .",
|
|
64
|
+
"test": "npm run typecheck && npm run lint && node --test --experimental-test-coverage --test-coverage-include=\"src/**/*.ts\" --test-coverage-exclude=\"src/**/*.spec.ts\" --test-coverage-lines=90 \"src/**/*.spec.ts\"",
|
|
65
|
+
"prepack": "npm run build",
|
|
66
|
+
"prepublishOnly": "npm test && npm run build"
|
|
67
|
+
},
|
|
68
|
+
"dependencies": {
|
|
69
|
+
"@starci/auth": "0.2.0",
|
|
70
|
+
"jose": "6.2.12"
|
|
71
|
+
},
|
|
72
|
+
"peerDependencies": {
|
|
73
|
+
"@nestjs/common": "^11.0.0",
|
|
74
|
+
"@nestjs/core": "^11.0.0",
|
|
75
|
+
"reflect-metadata": "^0.1.12 || ^0.2.0"
|
|
76
|
+
},
|
|
77
|
+
"peerDependenciesMeta": {
|
|
78
|
+
"@nestjs/common": {
|
|
79
|
+
"optional": true
|
|
80
|
+
},
|
|
81
|
+
"@nestjs/core": {
|
|
82
|
+
"optional": true
|
|
83
|
+
},
|
|
84
|
+
"reflect-metadata": {
|
|
85
|
+
"optional": true
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
"devDependencies": {
|
|
89
|
+
"@eslint/js": "10.0.1",
|
|
90
|
+
"@nestjs/common": "11.2.5",
|
|
91
|
+
"@nestjs/core": "11.2.5",
|
|
92
|
+
"@nestjs/testing": "11.2.5",
|
|
93
|
+
"@starci/auth": "file:../auth",
|
|
94
|
+
"@types/node": "22.20.4",
|
|
95
|
+
"eslint": "10.13.0",
|
|
96
|
+
"jiti": "2.7.0",
|
|
97
|
+
"reflect-metadata": "0.2.2",
|
|
98
|
+
"rxjs": "7.8.2",
|
|
99
|
+
"typescript": "5.9.3",
|
|
100
|
+
"typescript-eslint": "8.72.0"
|
|
101
|
+
},
|
|
102
|
+
"publishConfig": {
|
|
103
|
+
"access": "public",
|
|
104
|
+
"provenance": true
|
|
105
|
+
}
|
|
106
|
+
}
|