@starci/auth 0.5.0 → 0.6.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 +11 -0
- package/README.md +103 -14
- package/dist/client/oidc/index.d.ts +5 -0
- package/dist/client/oidc/index.js +3 -0
- package/dist/client/oidc/oidc-client.d.ts +85 -0
- package/dist/client/oidc/oidc-client.js +304 -0
- package/dist/client/oidc/pkce.d.ts +15 -0
- package/dist/client/oidc/pkce.js +29 -0
- package/dist/contracts/index.d.ts +12 -0
- package/dist/contracts/index.js +14 -0
- package/dist/contracts/oidc.d.ts +148 -0
- package/dist/contracts/oidc.js +68 -0
- package/dist/oidc/admin.d.ts +42 -0
- package/dist/oidc/admin.js +139 -0
- package/dist/oidc/broker.d.ts +57 -0
- package/dist/oidc/broker.js +77 -0
- package/dist/oidc/client.d.ts +58 -0
- package/dist/oidc/client.js +119 -0
- package/dist/oidc/discovery.d.ts +23 -0
- package/dist/oidc/discovery.js +64 -0
- package/dist/oidc/errors.d.ts +10 -0
- package/dist/oidc/errors.js +10 -0
- package/dist/oidc/grants.d.ts +41 -0
- package/dist/oidc/grants.js +82 -0
- package/dist/oidc/index.d.ts +35 -0
- package/dist/oidc/index.js +18 -0
- package/dist/oidc/issuers/github-actions.d.ts +54 -0
- package/dist/oidc/issuers/github-actions.js +84 -0
- package/dist/oidc/issuers/google.d.ts +30 -0
- package/dist/oidc/issuers/google.js +39 -0
- package/dist/oidc/issuers/index.d.ts +8 -0
- package/dist/oidc/issuers/index.js +4 -0
- package/dist/oidc/issuers/issuer.d.ts +79 -0
- package/dist/oidc/issuers/issuer.js +39 -0
- package/dist/oidc/issuers/keycloak.d.ts +26 -0
- package/dist/oidc/issuers/keycloak.js +41 -0
- package/dist/oidc/jwks.d.ts +26 -0
- package/dist/oidc/jwks.js +80 -0
- package/dist/oidc/oidc.d.ts +32 -0
- package/dist/oidc/oidc.js +21 -0
- package/dist/oidc/oidc.module.d.ts +34 -0
- package/dist/oidc/oidc.module.js +51 -0
- package/dist/oidc/oidc.tokens.d.ts +18 -0
- package/dist/oidc/oidc.tokens.js +18 -0
- package/dist/oidc/options.d.ts +119 -0
- package/dist/oidc/options.js +98 -0
- package/dist/oidc/primitives.d.ts +15 -0
- package/dist/oidc/primitives.js +11 -0
- package/dist/oidc/refresh.d.ts +10 -0
- package/dist/oidc/refresh.js +24 -0
- package/dist/oidc/roles.d.ts +12 -0
- package/dist/oidc/roles.js +51 -0
- package/dist/oidc/tokens.d.ts +26 -0
- package/dist/oidc/tokens.js +79 -0
- package/dist/oidc/transport.d.ts +8 -0
- package/dist/oidc/transport.js +19 -0
- package/dist/oidc/verifier.d.ts +24 -0
- package/dist/oidc/verifier.js +64 -0
- package/dist/server/auth.module.d.ts +1 -1
- package/dist/server/auth.module.js +20 -6
- package/dist/server/auth.tokens.d.ts +2 -0
- package/dist/server/auth.tokens.js +2 -0
- package/dist/server/doors.d.ts +14 -8
- package/dist/server/doors.js +22 -11
- package/dist/server/flows.d.ts +13 -5
- package/dist/server/flows.js +37 -18
- package/dist/server/index.d.ts +7 -7
- package/dist/server/index.js +3 -4
- package/dist/server/options.d.ts +11 -13
- package/dist/server/ports.d.ts +8 -1
- package/dist/server/providers/github.d.ts +59 -0
- package/dist/server/providers/github.js +111 -0
- package/dist/server/providers/google.d.ts +56 -0
- package/dist/server/providers/google.js +87 -0
- package/dist/server/providers/index.d.ts +14 -0
- package/dist/server/providers/index.js +9 -0
- package/dist/server/providers/oauth-state.d.ts +47 -0
- package/dist/server/providers/oauth-state.js +74 -0
- package/dist/server/providers/provider.d.ts +58 -0
- package/dist/server/providers/provider.js +32 -0
- package/dist/server/providers/registry.d.ts +30 -0
- package/dist/server/providers/registry.js +37 -0
- package/dist/server/strategies.d.ts +3 -18
- package/dist/server/strategies.js +2 -41
- package/dist/server/verifier.d.ts +1 -1
- package/dist/testing/index.d.ts +3 -0
- package/dist/testing/index.js +2 -0
- package/dist/testing/oidc.d.ts +175 -0
- package/dist/testing/oidc.js +493 -0
- package/package.json +22 -7
- package/dist/server/google-state.d.ts +0 -33
- package/dist/server/google-state.js +0 -73
|
@@ -0,0 +1,493 @@
|
|
|
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/oidc.js";
|
|
5
|
+
const KEYCLOAK_PATHS = {
|
|
6
|
+
authorization: "/protocol/openid-connect/auth",
|
|
7
|
+
token: "/protocol/openid-connect/token",
|
|
8
|
+
certs: "/protocol/openid-connect/certs",
|
|
9
|
+
logout: "/protocol/openid-connect/logout",
|
|
10
|
+
revoke: "/protocol/openid-connect/revoke",
|
|
11
|
+
};
|
|
12
|
+
const DISCOVERY = "/.well-known/openid-configuration";
|
|
13
|
+
const SECOND_MS = 1000;
|
|
14
|
+
const s256 = (verifier) => createHash("sha256").update(verifier).digest("base64url");
|
|
15
|
+
const generateKey = async (algorithm) => {
|
|
16
|
+
const pair = await generateKeyPair(algorithm, { extractable: true });
|
|
17
|
+
const exported = await exportJWK(pair.publicKey);
|
|
18
|
+
const kid = await calculateJwkThumbprint(exported);
|
|
19
|
+
return { kid, privateKey: pair.privateKey, jwk: { ...exported, kid, alg: algorithm, use: "sig" } };
|
|
20
|
+
};
|
|
21
|
+
const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
22
|
+
const form = (value) => Object.fromEntries(new URLSearchParams(value));
|
|
23
|
+
const readBody = async (request) => {
|
|
24
|
+
const chunks = [];
|
|
25
|
+
for await (const chunk of request)
|
|
26
|
+
chunks.push(Buffer.from(chunk));
|
|
27
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* A fake OIDC provider that behaves like Keycloak for what the package uses: the discovery document, the JWKS, the token endpoint (authorization
|
|
31
|
+
* code with PKCE, refresh token with rotation) and the logout endpoint, with keys that rotate and tokens a spec can bend (expired, wrong
|
|
32
|
+
* audience, wrong issuer, unknown key, foreign signature). Its `http` is an in-memory `OidcHttp` (no socket, no port), so an app's spec registers
|
|
33
|
+
* the module against it and needs no Keycloak; `listen: true` serves the same issuer on a real socket for a browser.
|
|
34
|
+
*/
|
|
35
|
+
export class FakeOidcIssuer {
|
|
36
|
+
clientId;
|
|
37
|
+
#realm;
|
|
38
|
+
#algorithm;
|
|
39
|
+
#audience;
|
|
40
|
+
#secret;
|
|
41
|
+
#clock;
|
|
42
|
+
#accessTtl;
|
|
43
|
+
#ring = [];
|
|
44
|
+
#codes = new Map();
|
|
45
|
+
#refreshTokens = new Map();
|
|
46
|
+
#refreshTtl;
|
|
47
|
+
#providers;
|
|
48
|
+
#revocation;
|
|
49
|
+
#minPassword;
|
|
50
|
+
#users = new Map();
|
|
51
|
+
#adminTokens = new Map();
|
|
52
|
+
#hints = [];
|
|
53
|
+
#failures = [];
|
|
54
|
+
#requests = [];
|
|
55
|
+
#issuer;
|
|
56
|
+
#server;
|
|
57
|
+
#down = false;
|
|
58
|
+
#idOverrides = {};
|
|
59
|
+
constructor(options, issuer) {
|
|
60
|
+
this.clientId = options.clientId ?? "test-client";
|
|
61
|
+
this.#realm = options.realm ?? "test";
|
|
62
|
+
this.#algorithm = options.algorithm ?? "RS256";
|
|
63
|
+
this.#audience = options.audience ?? this.clientId;
|
|
64
|
+
this.#secret = options.clientSecret;
|
|
65
|
+
this.#clock = options.clock ?? { now: () => new Date() };
|
|
66
|
+
this.#accessTtl = options.accessTtlSeconds ?? 300;
|
|
67
|
+
this.#refreshTtl = options.refreshTtlSeconds ?? 1800;
|
|
68
|
+
this.#providers = options.identityProviders;
|
|
69
|
+
this.#revocation = options.revocation ?? true;
|
|
70
|
+
this.#minPassword = options.minPasswordLength ?? 8;
|
|
71
|
+
this.#issuer = issuer;
|
|
72
|
+
}
|
|
73
|
+
/** Creates an issuer with one signing key, and starts its socket when `listen` is set. */
|
|
74
|
+
static async create(options = {}) {
|
|
75
|
+
const realm = options.realm ?? "test";
|
|
76
|
+
const issuer = new FakeOidcIssuer(options, `${options.origin ?? "https://idp.test"}/realms/${realm}`);
|
|
77
|
+
issuer.#ring.push(await generateKey(issuer.#algorithm));
|
|
78
|
+
if (options.listen === true)
|
|
79
|
+
await issuer.#start();
|
|
80
|
+
return issuer;
|
|
81
|
+
}
|
|
82
|
+
/** The exact issuer: the `iss` claim and the `issuer` of the discovery document. */
|
|
83
|
+
get issuer() {
|
|
84
|
+
return this.#issuer;
|
|
85
|
+
}
|
|
86
|
+
/** The in-memory outbound port: answers like the issuer's endpoints, with the failures the spec injected. */
|
|
87
|
+
get http() {
|
|
88
|
+
return { request: (request) => this.#serve(request) };
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* 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
|
|
92
|
+
* test-world fake does. Tokens minted afterwards carry the new `iss` and the discovery document names the new endpoints; the keys stay.
|
|
93
|
+
*/
|
|
94
|
+
rebase(origin) {
|
|
95
|
+
this.#issuer = `${origin}/realms/${this.#realm}`;
|
|
96
|
+
}
|
|
97
|
+
/** 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. */
|
|
98
|
+
serve(request) {
|
|
99
|
+
return this.#serve(request);
|
|
100
|
+
}
|
|
101
|
+
/** The same endpoints as a `fetch`, for a front-end client under test (the browser client takes a `fetch`, not an `OidcHttp`). */
|
|
102
|
+
get fetch() {
|
|
103
|
+
return async (input, init) => {
|
|
104
|
+
const method = init?.method === "POST" ? "POST" : "GET";
|
|
105
|
+
const text = typeof init?.body === "string" ? init.body : "";
|
|
106
|
+
const answer = await this.#serve({ method, url: typeof input === "string" ? input : input instanceof URL ? input.href : input.url, timeoutMs: 1000, ...(text === "" ? {} : { form: form(text) }) });
|
|
107
|
+
return new Response(JSON.stringify(answer.body), { status: answer.status, headers: { "content-type": "application/json" } });
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
/** The requests the issuer has answered, oldest first. */
|
|
111
|
+
get requests() {
|
|
112
|
+
return this.#requests;
|
|
113
|
+
}
|
|
114
|
+
/** The `kc_idp_hint` of every authorization address `authorize` has played, oldest first (an address without a hint adds nothing). */
|
|
115
|
+
get idpHints() {
|
|
116
|
+
return this.#hints;
|
|
117
|
+
}
|
|
118
|
+
/** Adds an account the password grant admits (by username or email) and the admin API lists. Answers it with its id. */
|
|
119
|
+
seedUser(user) {
|
|
120
|
+
const { password, ...rest } = user;
|
|
121
|
+
const seeded = { ...rest, id: user.id ?? randomUUID() };
|
|
122
|
+
this.#users.set(seeded.id, { user: seeded, password });
|
|
123
|
+
return seeded;
|
|
124
|
+
}
|
|
125
|
+
/** The account with this id, username or email (case-insensitive), as the issuer holds it now; undefined when there is none. */
|
|
126
|
+
findUser(identifier) {
|
|
127
|
+
return this.#lookup(identifier)?.user;
|
|
128
|
+
}
|
|
129
|
+
/** True when the account's current password is this one. */
|
|
130
|
+
passwordMatches(identifier, password) {
|
|
131
|
+
return this.#lookup(identifier)?.password === password;
|
|
132
|
+
}
|
|
133
|
+
/** How many refresh tokens the issuer holds that can still be used. */
|
|
134
|
+
get liveRefreshTokens() {
|
|
135
|
+
return [...this.#refreshTokens.values()].filter((held) => !held.used).length;
|
|
136
|
+
}
|
|
137
|
+
/** How many requests asked this path (`/protocol/openid-connect/certs`, `/.well-known/openid-configuration`). */
|
|
138
|
+
count(path) {
|
|
139
|
+
return this.#requests.filter((request) => request.path === path).length;
|
|
140
|
+
}
|
|
141
|
+
/** The issuer and key set addresses an issuer preset takes as `endpoints` to stand this double in for Google or GitHub Actions. */
|
|
142
|
+
endpoints() {
|
|
143
|
+
return { issuer: this.#issuer, jwksUri: `${this.#issuer}${KEYCLOAK_PATHS.certs}` };
|
|
144
|
+
}
|
|
145
|
+
/** 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. */
|
|
146
|
+
options(overrides = {}) {
|
|
147
|
+
const { callbackUri, postLogoutUri, ...rest } = overrides;
|
|
148
|
+
return {
|
|
149
|
+
issuer: this.#issuer,
|
|
150
|
+
audience: this.#audience,
|
|
151
|
+
http: this.http,
|
|
152
|
+
clock: this.#clock,
|
|
153
|
+
allowInsecureHttp: this.#issuer.startsWith("http:"),
|
|
154
|
+
...(callbackUri === undefined
|
|
155
|
+
? {}
|
|
156
|
+
: {
|
|
157
|
+
client: {
|
|
158
|
+
clientId: this.clientId,
|
|
159
|
+
...(this.#secret === undefined ? {} : { clientSecret: this.#secret }),
|
|
160
|
+
callbackUri,
|
|
161
|
+
postLogoutUri: postLogoutUri ?? new URL("/signed-out", callbackUri).toString(),
|
|
162
|
+
},
|
|
163
|
+
}),
|
|
164
|
+
...rest,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
/** The discovery document. */
|
|
168
|
+
metadata() {
|
|
169
|
+
const base = `${this.#issuer}/protocol/openid-connect`;
|
|
170
|
+
return {
|
|
171
|
+
issuer: this.#issuer,
|
|
172
|
+
authorization_endpoint: `${base}/auth`,
|
|
173
|
+
token_endpoint: `${base}/token`,
|
|
174
|
+
jwks_uri: `${base}/certs`,
|
|
175
|
+
end_session_endpoint: `${base}/logout`,
|
|
176
|
+
...(this.#revocation ? { revocation_endpoint: `${base}/revoke` } : {}),
|
|
177
|
+
id_token_signing_alg_values_supported: [this.#algorithm],
|
|
178
|
+
response_types_supported: ["code"],
|
|
179
|
+
code_challenge_methods_supported: ["S256"],
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
/** The published key set: every key of the ring. */
|
|
183
|
+
jwks() {
|
|
184
|
+
return { keys: this.#ring.map((key) => key.jwk) };
|
|
185
|
+
}
|
|
186
|
+
/** The `kid` of the key that signs now. */
|
|
187
|
+
get keyId() {
|
|
188
|
+
return this.#signer().kid;
|
|
189
|
+
}
|
|
190
|
+
/** 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`. */
|
|
191
|
+
async rotateKeys(options = {}) {
|
|
192
|
+
const fresh = await generateKey(this.#algorithm);
|
|
193
|
+
if (options.keepOld === false)
|
|
194
|
+
this.#ring.length = 0;
|
|
195
|
+
this.#ring.unshift(fresh);
|
|
196
|
+
return fresh.kid;
|
|
197
|
+
}
|
|
198
|
+
/** Mints an access token for the claims, signed by the current key unless an override bends it. */
|
|
199
|
+
mintAccessToken(claims = {}, overrides = {}) {
|
|
200
|
+
return this.#mint(claims, { type: "Bearer", ...overrides }, {});
|
|
201
|
+
}
|
|
202
|
+
/** Mints an ID token for the client, carrying the nonce of the transaction. */
|
|
203
|
+
mintIdToken(claims = {}, nonce, overrides = {}) {
|
|
204
|
+
return this.#mint(claims, { type: "ID", audience: this.clientId, ...overrides }, nonce === undefined ? {} : { nonce });
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* 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
|
|
208
|
+
* answers the address the provider would redirect the browser to (the redirect address with `code`, `state` and `iss`).
|
|
209
|
+
*/
|
|
210
|
+
authorize(authorizationUrl, claims = {}) {
|
|
211
|
+
const query = new URL(authorizationUrl).searchParams;
|
|
212
|
+
const redirectUri = query.get("redirect_uri");
|
|
213
|
+
const state = query.get("state");
|
|
214
|
+
if (redirectUri === null || state === null || query.get("client_id") !== this.clientId || query.get("code_challenge_method") !== "S256") {
|
|
215
|
+
throw new Error("fake OIDC issuer: the authorization request is not a registered S256 code flow");
|
|
216
|
+
}
|
|
217
|
+
const hint = query.get("kc_idp_hint");
|
|
218
|
+
if (hint !== null) {
|
|
219
|
+
if (this.#providers !== undefined && !this.#providers.includes(hint))
|
|
220
|
+
throw new Error("fake OIDC issuer: the realm brokers no identity provider with this alias");
|
|
221
|
+
this.#hints.push(hint);
|
|
222
|
+
}
|
|
223
|
+
const code = randomUUID();
|
|
224
|
+
this.#codes.set(code, { claims, nonce: query.get("nonce") ?? "", challenge: query.get("code_challenge") ?? "", redirectUri, used: false });
|
|
225
|
+
const back = new URL(redirectUri);
|
|
226
|
+
back.search = new URLSearchParams({ code, state, iss: this.#issuer }).toString();
|
|
227
|
+
return back.toString();
|
|
228
|
+
}
|
|
229
|
+
/** Makes the next `count` requests fail: with an HTTP status, or as a lost connection. */
|
|
230
|
+
failNext(failure = { status: 503 }, count = 1) {
|
|
231
|
+
for (let n = 0; n < count; n += 1)
|
|
232
|
+
this.#failures.push(failure);
|
|
233
|
+
}
|
|
234
|
+
/** Bends every ID token the token endpoint mints from now on (an empty object restores them). */
|
|
235
|
+
bendIdTokens(overrides = {}) {
|
|
236
|
+
this.#idOverrides = overrides;
|
|
237
|
+
}
|
|
238
|
+
/** Ends every refresh token the issuer holds, as an administrator signing the person out of the provider would: the next refresh answers `invalid_grant`. */
|
|
239
|
+
revokeRefreshTokens() {
|
|
240
|
+
this.#refreshTokens.clear();
|
|
241
|
+
}
|
|
242
|
+
/** While down, every request fails as a lost connection (an outage); `false` brings the issuer back. */
|
|
243
|
+
setDown(down) {
|
|
244
|
+
this.#down = down;
|
|
245
|
+
}
|
|
246
|
+
/** Stops the socket, when there is one. */
|
|
247
|
+
async close() {
|
|
248
|
+
const server = this.#server;
|
|
249
|
+
if (server === undefined)
|
|
250
|
+
return;
|
|
251
|
+
this.#server = undefined;
|
|
252
|
+
server.closeAllConnections();
|
|
253
|
+
await new Promise((resolve) => {
|
|
254
|
+
server.close(() => {
|
|
255
|
+
resolve();
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
#signer() {
|
|
260
|
+
const [key] = this.#ring;
|
|
261
|
+
if (key === undefined)
|
|
262
|
+
throw new Error("fake OIDC issuer: no signing key");
|
|
263
|
+
return key;
|
|
264
|
+
}
|
|
265
|
+
async #mint(claims, overrides, extra) {
|
|
266
|
+
const signer = overrides.signer === "foreign" ? await generateKey(this.#algorithm) : this.#signer();
|
|
267
|
+
const issuedAt = Math.floor((overrides.issuedAt ?? this.#clock.now()).getTime() / SECOND_MS);
|
|
268
|
+
const subject = claims.sub ?? "user-1";
|
|
269
|
+
const realm = claims.roles === undefined ? {} : { [OIDC_CLAIMS.realmAccess]: { roles: claims.roles } };
|
|
270
|
+
const clients = claims.clientRoles === undefined
|
|
271
|
+
? {}
|
|
272
|
+
: { [OIDC_CLAIMS.resourceAccess]: Object.fromEntries(Object.entries(claims.clientRoles).map(([client, roles]) => [client, { roles }])) };
|
|
273
|
+
const jwt = new SignJWT({
|
|
274
|
+
...realm,
|
|
275
|
+
...clients,
|
|
276
|
+
[OIDC_CLAIMS.sessionId]: claims.sessionId ?? `sid-${subject}`,
|
|
277
|
+
[OIDC_CLAIMS.authorizedParty]: this.clientId,
|
|
278
|
+
...(overrides.type === undefined ? {} : { [OIDC_CLAIMS.tokenType]: overrides.type }),
|
|
279
|
+
...(claims.email === undefined ? {} : { [OIDC_CLAIMS.email]: claims.email }),
|
|
280
|
+
...(claims.name === undefined ? {} : { [OIDC_CLAIMS.name]: claims.name }),
|
|
281
|
+
...(claims.username === undefined ? {} : { [OIDC_CLAIMS.username]: claims.username }),
|
|
282
|
+
...claims.extra,
|
|
283
|
+
...extra,
|
|
284
|
+
})
|
|
285
|
+
.setProtectedHeader({ alg: this.#algorithm, typ: "JWT", ...(overrides.kid === null ? {} : { kid: overrides.kid ?? signer.kid }) })
|
|
286
|
+
.setSubject(subject)
|
|
287
|
+
.setIssuer(overrides.issuer ?? this.#issuer)
|
|
288
|
+
.setAudience(overrides.audience === undefined ? this.#audience : typeof overrides.audience === "string" ? overrides.audience : [...overrides.audience])
|
|
289
|
+
.setJti(randomUUID())
|
|
290
|
+
.setIssuedAt(issuedAt)
|
|
291
|
+
.setExpirationTime(issuedAt + (overrides.expiresInSeconds ?? this.#accessTtl));
|
|
292
|
+
if (overrides.notBefore !== undefined)
|
|
293
|
+
jwt.setNotBefore(Math.floor(overrides.notBefore.getTime() / SECOND_MS));
|
|
294
|
+
return jwt.sign(signer.privateKey);
|
|
295
|
+
}
|
|
296
|
+
async #serve(request) {
|
|
297
|
+
const url = new URL(request.url);
|
|
298
|
+
const path = url.pathname.startsWith(new URL(this.#issuer).pathname) ? url.pathname.slice(new URL(this.#issuer).pathname.length) : url.pathname;
|
|
299
|
+
this.#requests.push({ method: request.method, path });
|
|
300
|
+
if (this.#down)
|
|
301
|
+
throw new Error("fake OIDC issuer: connection refused");
|
|
302
|
+
const failure = this.#failures.shift();
|
|
303
|
+
if (failure === "network")
|
|
304
|
+
throw new Error("fake OIDC issuer: connection reset");
|
|
305
|
+
if (failure !== undefined)
|
|
306
|
+
return { status: failure.status, body: { error: "server_error" } };
|
|
307
|
+
return this.#handle({ method: request.method, path, query: url.searchParams, params: request.form ?? {}, body: request.body, authorization: request.headers?.authorization });
|
|
308
|
+
}
|
|
309
|
+
async #handle(inbound) {
|
|
310
|
+
const { method, path, params } = inbound;
|
|
311
|
+
if (method === "GET" && path === DISCOVERY)
|
|
312
|
+
return { status: 200, body: this.metadata() };
|
|
313
|
+
if (method === "GET" && path === KEYCLOAK_PATHS.certs)
|
|
314
|
+
return { status: 200, body: this.jwks() };
|
|
315
|
+
if (method === "GET" && path === KEYCLOAK_PATHS.logout)
|
|
316
|
+
return { status: 200, body: {} };
|
|
317
|
+
if (method === "POST" && path === KEYCLOAK_PATHS.token)
|
|
318
|
+
return this.#token(params);
|
|
319
|
+
if (method === "POST" && (path === KEYCLOAK_PATHS.revoke || path === KEYCLOAK_PATHS.logout))
|
|
320
|
+
return this.#revoke(params, path === KEYCLOAK_PATHS.logout);
|
|
321
|
+
if (path.startsWith(this.#adminBase()))
|
|
322
|
+
return this.#admin(inbound);
|
|
323
|
+
return { status: 404, body: { error: "not_found" } };
|
|
324
|
+
}
|
|
325
|
+
#adminBase() {
|
|
326
|
+
return `/admin/realms/${this.#realm}`;
|
|
327
|
+
}
|
|
328
|
+
#lookup(identifier) {
|
|
329
|
+
const wanted = identifier.toLowerCase();
|
|
330
|
+
return this.#users.get(identifier) ?? [...this.#users.values()].find(({ user }) => user.username.toLowerCase() === wanted || user.email?.toLowerCase() === wanted);
|
|
331
|
+
}
|
|
332
|
+
#claimsOf(user) {
|
|
333
|
+
return { sub: user.id, username: user.username, email: user.email, name: user.name, roles: user.roles };
|
|
334
|
+
}
|
|
335
|
+
#policy(password) {
|
|
336
|
+
if (typeof password === "string" && password.length >= this.#minPassword)
|
|
337
|
+
return undefined;
|
|
338
|
+
return { status: 400, body: { error: "invalid_password", error_description: `Invalid password: minimum length ${String(this.#minPassword)}.` } };
|
|
339
|
+
}
|
|
340
|
+
#revoke(params, endSession) {
|
|
341
|
+
if (params.client_id !== this.clientId || (this.#secret !== undefined && params.client_secret !== this.#secret))
|
|
342
|
+
return { status: 401, body: { error: "invalid_client" } };
|
|
343
|
+
const token = (endSession ? params.refresh_token : params.token) ?? "";
|
|
344
|
+
const known = this.#refreshTokens.delete(token);
|
|
345
|
+
// RFC 7009 answers 200 for a token it does not know; the end-session endpoint answers invalid_grant for one it cannot end.
|
|
346
|
+
if (!endSession)
|
|
347
|
+
return { status: 200, body: {} };
|
|
348
|
+
return known ? { status: 204, body: undefined } : { status: 400, body: { error: "invalid_grant" } };
|
|
349
|
+
}
|
|
350
|
+
#admin(inbound) {
|
|
351
|
+
const bearer = inbound.authorization?.startsWith("Bearer ") === true ? inbound.authorization.slice("Bearer ".length) : "";
|
|
352
|
+
const until = this.#adminTokens.get(bearer);
|
|
353
|
+
if (until === undefined || until <= this.#clock.now().getTime())
|
|
354
|
+
return { status: 401, body: { error: "HTTP 401 Unauthorized" } };
|
|
355
|
+
const rest = inbound.path.slice(this.#adminBase().length);
|
|
356
|
+
const body = isObject(inbound.body) ? inbound.body : {};
|
|
357
|
+
if (rest === "/users" && inbound.method === "POST")
|
|
358
|
+
return this.#createUser(body);
|
|
359
|
+
if (rest === "/users" && inbound.method === "GET")
|
|
360
|
+
return this.#listUsers(inbound.query);
|
|
361
|
+
const match = /^\/users\/([^/]+)\/(reset-password|logout)$/u.exec(rest);
|
|
362
|
+
if (match === null || (match[2] === "reset-password") !== (inbound.method === "PUT"))
|
|
363
|
+
return { status: 404, body: { error: "not_found" } };
|
|
364
|
+
const held = this.#users.get(decodeURIComponent(match[1] ?? ""));
|
|
365
|
+
if (held === undefined)
|
|
366
|
+
return { status: 404, body: { error: "User not found" } };
|
|
367
|
+
if (match[2] === "logout") {
|
|
368
|
+
for (const [token, refresh] of this.#refreshTokens)
|
|
369
|
+
if ((refresh.claims.sub ?? "user-1") === held.user.id)
|
|
370
|
+
this.#refreshTokens.delete(token);
|
|
371
|
+
return { status: 204, body: undefined };
|
|
372
|
+
}
|
|
373
|
+
const refused = this.#policy(body.value);
|
|
374
|
+
if (refused !== undefined)
|
|
375
|
+
return refused;
|
|
376
|
+
held.password = String(body.value);
|
|
377
|
+
return { status: 204, body: undefined };
|
|
378
|
+
}
|
|
379
|
+
#createUser(body) {
|
|
380
|
+
const credential = Array.isArray(body.credentials) ? body.credentials[0] : undefined;
|
|
381
|
+
if (typeof body.username !== "string" || typeof body.email !== "string" || !isObject(credential))
|
|
382
|
+
return { status: 400, body: { error: "invalid_request" } };
|
|
383
|
+
if (this.#lookup(body.username) !== undefined || this.#lookup(body.email) !== undefined)
|
|
384
|
+
return { status: 409, body: { errorMessage: "User exists with same username or email" } };
|
|
385
|
+
const refused = this.#policy(credential.value);
|
|
386
|
+
if (refused !== undefined)
|
|
387
|
+
return refused;
|
|
388
|
+
const user = this.seedUser({ username: body.username, email: body.email, password: String(credential.value) });
|
|
389
|
+
return { status: 201, body: undefined, location: `${new URL(this.#issuer).origin}${this.#adminBase()}/users/${user.id}` };
|
|
390
|
+
}
|
|
391
|
+
#listUsers(query) {
|
|
392
|
+
const email = query.get("email");
|
|
393
|
+
const username = query.get("username");
|
|
394
|
+
const found = [...this.#users.values()]
|
|
395
|
+
.map(({ user }) => user)
|
|
396
|
+
.filter((user) => (email === null || user.email?.toLowerCase() === email.toLowerCase()) && (username === null || user.username.toLowerCase() === username.toLowerCase()));
|
|
397
|
+
return { status: 200, body: found.map((user) => ({ id: user.id, username: user.username, email: user.email, enabled: user.enabled ?? true })) };
|
|
398
|
+
}
|
|
399
|
+
async #token(params) {
|
|
400
|
+
if (params.client_id !== this.clientId || (this.#secret !== undefined && params.client_secret !== this.#secret))
|
|
401
|
+
return { status: 401, body: { error: "invalid_client" } };
|
|
402
|
+
if (params.grant_type === "authorization_code")
|
|
403
|
+
return this.#exchangeCode(params);
|
|
404
|
+
if (params.grant_type === "refresh_token")
|
|
405
|
+
return this.#refresh(params.refresh_token);
|
|
406
|
+
if (params.grant_type === "password")
|
|
407
|
+
return this.#password(params);
|
|
408
|
+
if (params.grant_type === "client_credentials")
|
|
409
|
+
return this.#serviceAccount();
|
|
410
|
+
return { status: 400, body: { error: "unsupported_grant_type" } };
|
|
411
|
+
}
|
|
412
|
+
async #exchangeCode(params) {
|
|
413
|
+
const pending = this.#codes.get(params.code ?? "");
|
|
414
|
+
const verifier = params.code_verifier ?? "";
|
|
415
|
+
if (pending === undefined || pending.used || pending.redirectUri !== params.redirect_uri || s256(verifier) !== pending.challenge) {
|
|
416
|
+
return { status: 400, body: { error: "invalid_grant" } };
|
|
417
|
+
}
|
|
418
|
+
pending.used = true;
|
|
419
|
+
return { status: 200, body: await this.#grant(pending.claims, pending.nonce) };
|
|
420
|
+
}
|
|
421
|
+
async #password(params) {
|
|
422
|
+
const held = this.#lookup(params.username ?? "");
|
|
423
|
+
if (held === undefined || params.password === undefined || held.password !== params.password)
|
|
424
|
+
return { status: 401, body: { error: "invalid_grant", error_description: "Invalid user credentials" } };
|
|
425
|
+
if (held.user.enabled === false)
|
|
426
|
+
return { status: 400, body: { error: "invalid_grant", error_description: "Account disabled" } };
|
|
427
|
+
return { status: 200, body: await this.#grant(this.#claimsOf(held.user), undefined) };
|
|
428
|
+
}
|
|
429
|
+
async #serviceAccount() {
|
|
430
|
+
if (this.#secret === undefined)
|
|
431
|
+
return { status: 400, body: { error: "unauthorized_client" } };
|
|
432
|
+
const name = `service-account-${this.clientId}`;
|
|
433
|
+
const token = await this.mintAccessToken({ sub: name, username: name });
|
|
434
|
+
this.#adminTokens.set(token, this.#clock.now().getTime() + this.#accessTtl * SECOND_MS);
|
|
435
|
+
return { status: 200, body: { token_type: "Bearer", access_token: token, expires_in: this.#accessTtl } };
|
|
436
|
+
}
|
|
437
|
+
async #refresh(token) {
|
|
438
|
+
const held = token === undefined ? undefined : this.#refreshTokens.get(token);
|
|
439
|
+
if (held === undefined || held.used)
|
|
440
|
+
return { status: 400, body: { error: "invalid_grant" } };
|
|
441
|
+
held.used = true;
|
|
442
|
+
return { status: 200, body: await this.#grant(held.claims, undefined) };
|
|
443
|
+
}
|
|
444
|
+
async #grant(claims, nonce) {
|
|
445
|
+
const refreshToken = randomUUID();
|
|
446
|
+
this.#refreshTokens.set(refreshToken, { claims, used: false });
|
|
447
|
+
return {
|
|
448
|
+
token_type: "Bearer",
|
|
449
|
+
access_token: await this.mintAccessToken(claims),
|
|
450
|
+
id_token: await this.mintIdToken(claims, nonce, this.#idOverrides),
|
|
451
|
+
refresh_token: refreshToken,
|
|
452
|
+
expires_in: this.#accessTtl,
|
|
453
|
+
refresh_expires_in: this.#refreshTtl,
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
async #start() {
|
|
457
|
+
const server = createServer((request, response) => {
|
|
458
|
+
void this.#respond(request, response);
|
|
459
|
+
});
|
|
460
|
+
this.#server = server;
|
|
461
|
+
await new Promise((resolve) => {
|
|
462
|
+
server.listen(0, "127.0.0.1", resolve);
|
|
463
|
+
});
|
|
464
|
+
const { port } = server.address();
|
|
465
|
+
this.#issuer = `http://127.0.0.1:${String(port)}/realms/${this.#realm}`;
|
|
466
|
+
}
|
|
467
|
+
async #respond(request, response) {
|
|
468
|
+
const cors = { "access-control-allow-origin": "*", "access-control-allow-headers": "content-type, authorization", "access-control-allow-methods": "GET, POST, PUT, OPTIONS" };
|
|
469
|
+
if (request.method === "OPTIONS") {
|
|
470
|
+
response.writeHead(204, cors).end();
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
const method = request.method === "POST" || request.method === "PUT" ? request.method : "GET";
|
|
474
|
+
const text = method === "GET" ? "" : await readBody(request);
|
|
475
|
+
const json = request.headers["content-type"]?.startsWith("application/json") === true;
|
|
476
|
+
const authorization = request.headers.authorization;
|
|
477
|
+
try {
|
|
478
|
+
const answer = await this.#serve({
|
|
479
|
+
method,
|
|
480
|
+
url: `${this.#issuer.slice(0, this.#issuer.indexOf("/realms/"))}${request.url ?? "/"}`,
|
|
481
|
+
timeoutMs: 1000,
|
|
482
|
+
...(authorization === undefined ? {} : { headers: { authorization } }),
|
|
483
|
+
...(text === "" ? {} : json ? { body: JSON.parse(text) } : { form: form(text) }),
|
|
484
|
+
});
|
|
485
|
+
response
|
|
486
|
+
.writeHead(answer.status, { ...cors, "content-type": "application/json", ...(answer.location === undefined ? {} : { location: answer.location }) })
|
|
487
|
+
.end(answer.body === undefined ? undefined : JSON.stringify(answer.body));
|
|
488
|
+
}
|
|
489
|
+
catch {
|
|
490
|
+
response.destroy();
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@starci/auth",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "The sign-in of a StarCi
|
|
3
|
+
"version": "0.6.0",
|
|
4
|
+
"description": "The sign-in of a StarCi app in one package, for both editions. Lite: a NestJS module (EdDSA access tokens with a multi-key JWKS, bcrypt passwords, pluggable OAuth sign-in providers with state and PKCE (Google, GitHub), rotating refresh tokens with a reuse grace window and reuse detection, a failures-only sign-in limit, the refresh cookie). Full: the OIDC client of the identity provider (discovery, JWKS with caching and rotation, algorithm-pinned token checks, role mapping, the authorization code flow with PKCE and nonce, Keycloak brokering and admin) and issuer presets (Keycloak, Google ID tokens, GitHub Actions OIDC). The door contracts, the front-end auth clients (lite and OIDC) with React bindings, and test doubles with a fake OIDC issuer.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"auth",
|
|
7
7
|
"jwt",
|
|
@@ -9,8 +9,13 @@
|
|
|
9
9
|
"eddsa",
|
|
10
10
|
"passport",
|
|
11
11
|
"google-oauth",
|
|
12
|
+
"github-oauth",
|
|
12
13
|
"pkce",
|
|
13
14
|
"refresh-token",
|
|
15
|
+
"oidc",
|
|
16
|
+
"openid-connect",
|
|
17
|
+
"keycloak",
|
|
18
|
+
"github-actions",
|
|
14
19
|
"nestjs",
|
|
15
20
|
"react"
|
|
16
21
|
],
|
|
@@ -34,6 +39,10 @@
|
|
|
34
39
|
"types": "./dist/server/index.d.ts",
|
|
35
40
|
"default": "./dist/server/index.js"
|
|
36
41
|
},
|
|
42
|
+
"./oidc": {
|
|
43
|
+
"types": "./dist/oidc/index.d.ts",
|
|
44
|
+
"default": "./dist/oidc/index.js"
|
|
45
|
+
},
|
|
37
46
|
"./client": {
|
|
38
47
|
"types": "./dist/client/index.d.ts",
|
|
39
48
|
"default": "./dist/client/index.js"
|
|
@@ -42,6 +51,10 @@
|
|
|
42
51
|
"types": "./dist/react/index.d.ts",
|
|
43
52
|
"default": "./dist/react/index.js"
|
|
44
53
|
},
|
|
54
|
+
"./client/oidc": {
|
|
55
|
+
"types": "./dist/client/oidc/index.d.ts",
|
|
56
|
+
"default": "./dist/client/oidc/index.js"
|
|
57
|
+
},
|
|
45
58
|
"./contracts": {
|
|
46
59
|
"types": "./dist/contracts/index.d.ts",
|
|
47
60
|
"default": "./dist/contracts/index.js"
|
|
@@ -67,20 +80,21 @@
|
|
|
67
80
|
},
|
|
68
81
|
"scripts": {
|
|
69
82
|
"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))\"",
|
|
70
|
-
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
83
|
+
"typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.isolated.json",
|
|
71
84
|
"lint": "eslint .",
|
|
72
85
|
"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\"",
|
|
73
86
|
"prepack": "npm run build",
|
|
74
87
|
"prepublishOnly": "npm test && npm run build"
|
|
75
88
|
},
|
|
76
89
|
"dependencies": {
|
|
77
|
-
"@starci/common": "1.0.1",
|
|
78
90
|
"@nestjs/passport": "11.0.5",
|
|
91
|
+
"@starci/common": "1.0.1",
|
|
79
92
|
"bcryptjs": "3.0.3",
|
|
80
93
|
"jose": "6.2.12",
|
|
81
94
|
"passport": "0.7.0",
|
|
82
95
|
"passport-google-oauth20": "2.0.0",
|
|
83
|
-
"passport-local": "1.0.0"
|
|
96
|
+
"passport-local": "1.0.0",
|
|
97
|
+
"passport-oauth2": "1.8.0"
|
|
84
98
|
},
|
|
85
99
|
"peerDependencies": {
|
|
86
100
|
"@nestjs/common": "^11.0.0",
|
|
@@ -112,6 +126,7 @@
|
|
|
112
126
|
"@types/node": "22.20.4",
|
|
113
127
|
"@types/passport-google-oauth20": "2.0.17",
|
|
114
128
|
"@types/passport-local": "1.0.38",
|
|
129
|
+
"@types/passport-oauth2": "1.8.0",
|
|
115
130
|
"@types/react": "19.3.0",
|
|
116
131
|
"@types/react-dom": "19.3.0",
|
|
117
132
|
"eslint": "10.13.0",
|
|
@@ -127,6 +142,6 @@
|
|
|
127
142
|
"access": "public",
|
|
128
143
|
"provenance": true
|
|
129
144
|
},
|
|
130
|
-
"gitHead": "
|
|
131
|
-
"starciSource": "
|
|
145
|
+
"gitHead": "6747d857cc26e1f6348193180fd8a119cbdc330f",
|
|
146
|
+
"starciSource": "158d7eea780b1dd51a8f7ac2c287ff6e8a9bac77224b2ab9e1498ff1d72538c3"
|
|
132
147
|
}
|
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
import type { HttpRequest } from "./http.js";
|
|
2
|
-
import type { AuthOptions } from "./options.js";
|
|
3
|
-
import type { AuthClock, AuthIds } from "./ports.js";
|
|
4
|
-
import type { AuthTokens } from "./tokens.js";
|
|
5
|
-
/** The cookie that carries one Google round trip, when the options name none. */
|
|
6
|
-
export declare const DEFAULT_GOOGLE_STATE_COOKIE = "auth_google_state";
|
|
7
|
-
/** The only path the state cookie is sent to. */
|
|
8
|
-
export declare const GOOGLE_STATE_PATH: "/auth/google";
|
|
9
|
-
/** How long a Google round trip may take, in milliseconds. */
|
|
10
|
-
export declare const GOOGLE_STATE_TTL_MS = 600000;
|
|
11
|
-
type StoreDone = (error: Error | null, handle?: string) => void;
|
|
12
|
-
type VerifyDone = (error: Error | null, verifier?: unknown, context?: unknown) => void;
|
|
13
|
-
/**
|
|
14
|
-
* The state store passport-oauth2 calls for `state` and PKCE without a server session: the handle (echoed as `state`), the code
|
|
15
|
-
* verifier and the landing path travel in a signed, httpOnly cookie that only the callback path receives, and the handle is
|
|
16
|
-
* compared on return. The landing path is a same-site path only, so the callback can never redirect off the web origin.
|
|
17
|
-
*/
|
|
18
|
-
export declare class GoogleStateClient {
|
|
19
|
-
#private;
|
|
20
|
-
constructor(deps: {
|
|
21
|
-
readonly tokens: AuthTokens;
|
|
22
|
-
readonly clock: AuthClock;
|
|
23
|
-
readonly ids: AuthIds;
|
|
24
|
-
readonly options: Pick<AuthOptions, "returnOrigin" | "google">;
|
|
25
|
-
});
|
|
26
|
-
/** The name of the state cookie. */
|
|
27
|
-
get cookieName(): string;
|
|
28
|
-
/** Called by passport-oauth2 on the outbound leg (the five-argument PKCE form). */
|
|
29
|
-
store(req: HttpRequest, verifier: string, _state: unknown, _meta: unknown, done: StoreDone): void;
|
|
30
|
-
/** Called by passport-oauth2 on the return leg (the meta form, since this method takes four arguments): the cookie must be ours, unexpired and carry the same handle as `state`. */
|
|
31
|
-
verify(req: HttpRequest, providedState: string, _meta: unknown, done: VerifyDone): void;
|
|
32
|
-
}
|
|
33
|
-
export {};
|