@starci/auth-oidc 0.0.0-stage → 0.1.1
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 +14 -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
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.1 - 2026-11-03
|
|
4
|
+
|
|
5
|
+
- Changed: requires `@starci/auth` 0.2.1 (the raced refresh gets its own successor cookie; `SessionTokens.cookieKept` is gone). Nothing of this package reads it.
|
|
6
|
+
|
|
7
|
+
## 0.1.0 - 2026-11-03
|
|
8
|
+
|
|
9
|
+
The OIDC client of a full StarCi app (Keycloak) in one package, behind the `PrincipalVerifier` interface of `@starci/auth`. Lifted from the Keycloak integration of the first full app and generalised: no product names, every setting a typed option, no environment read.
|
|
10
|
+
|
|
11
|
+
- Added: `@starci/auth-oidc/server`, the NestJS module `OidcModule.register` / `registerAsync` (and `createOidc` without Nest). `OidcDiscovery` reads the discovery document of the exact issuer and trusts its endpoints only on the issuer's origin over https. `OidcKeys` fetches the JWKS, caches it, refreshes it on an unknown `kid` at most once per cooldown, rejects private and symmetric keys and keeps the cached keys through a provider outage. `OidcTokenVerifier` pins the algorithms before any key is looked up and checks issuer, audience, expiry and not-before. `OidcPrincipalVerifier` implements `PrincipalVerifier`: the principal has the shape of `@starci/auth`'s with the roles the issuer granted, an ID or refresh token presented as a bearer is refused, and a provider outage throws an `unavailable` error instead of refusing. `OidcClient` is the authorization code flow of a registered client: S256 PKCE, state, nonce, one code exchange, ID token verification, RP-initiated logout.
|
|
12
|
+
- Added: `@starci/auth-oidc/client`, `createOidcClient`: PKCE redirect, callback, `restore` (a `prompt=none` redirect), one shared refresh with refresh-ahead and backoff, the tokens in memory only, signed out only on a 401, a 403 or a 400 `invalid_grant`, a stable snapshot, an event listener and logout.
|
|
13
|
+
- Changed: the client keeps no copy of the session snapshot or of the refresh-race guard: it imports `createSessionStore` and `createSingleFlight` from `@starci/auth/client` and requires `@starci/auth` 0.2.0.
|
|
14
|
+
- Added: `@starci/auth-oidc/contracts` and `@starci/auth-oidc/testing` (`FakeOidcIssuer`: discovery, JWKS with rotation, access and ID token minting a spec can bend, the token endpoint with PKCE and rotating refresh tokens, failure injection, an in-memory port and a socket).
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 starci183
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,74 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @starci/auth-oidc
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The sign-in of a full StarCi app: the OIDC client of its identity provider (Keycloak), behind the same `PrincipalVerifier` interface as `@starci/auth` (the lite edition), so the guards, `@Public` and `@Credential` of an app do not change between editions. One package, four entries.
|
|
4
|
+
|
|
5
|
+
| entry | holds |
|
|
6
|
+
|---|---|
|
|
7
|
+
| `@starci/auth-oidc/server` | `OidcModule.register` / `registerAsync` (also `createOidc` without Nest): issuer discovery, the provider's JWKS with caching and rotation, the token verifier (algorithm pinned, issuer, audience, expiry, `typ`), `OidcPrincipalVerifier`, role mapping, and `OidcClient`, the authorization code flow (PKCE S256, state, nonce, code exchange, ID token verification, RP-initiated logout) |
|
|
8
|
+
| `@starci/auth-oidc/client` | `createOidcClient`: the browser half of a public client: PKCE redirect, callback, one shared refresh, the access token in memory, sign-out only when the provider ends the session |
|
|
9
|
+
| `@starci/auth-oidc/contracts` | algorithms, wire codes, claim names, the principal and the outcome shapes both sides read |
|
|
10
|
+
| `@starci/auth-oidc/testing` | `FakeOidcIssuer`: discovery, JWKS, token minting, the token endpoint and failure injection, in memory or on a socket, so an app tests without Keycloak |
|
|
11
|
+
|
|
12
|
+
An app reaches the package only through `integrations/keycloak` (back end) and `modules/auth/client.ts` (front end); the lint refuses `jose` and every other JWT library in an app.
|
|
13
|
+
|
|
14
|
+
## Back end
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
OidcModule.registerAsync({
|
|
18
|
+
isGlobal: true,
|
|
19
|
+
inject: [HTTP_CLIENT, CLOCK],
|
|
20
|
+
useFactory: (http: HttpClient, clock: Clock) => ({
|
|
21
|
+
...parsedOptions, // issuer, audience, client registration, role mapping, from the app's config loader
|
|
22
|
+
http: { request: (request) => http.request({ correlationId: "identity-keycloak", ...request }) },
|
|
23
|
+
clock,
|
|
24
|
+
}),
|
|
25
|
+
})
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The package reads no environment. The app parses its settings (BE-CONFIG-2) and passes typed options; the client secret is passed as a `{ reveal(): string }` holder or text and revealed only for the token request. Every provider request goes through the `OidcHttp` port, so the app's bounded HTTP client (`platform/http`) is the one door; without the port the platform `fetch` is used with the request's deadline.
|
|
29
|
+
|
|
30
|
+
| a bearer token | is checked by |
|
|
31
|
+
|---|---|
|
|
32
|
+
| the algorithm | pinned to `algorithms` (`RS256`) before any key is looked up; `none`, HMAC and unlisted algorithms are refused |
|
|
33
|
+
| the key | the JWKS of the discovered `jwks_uri`, selected by `kid`; cached 10 minutes; an unknown `kid` refreshes it at most once per 30 seconds (rotation); an unreachable provider keeps the cached keys for 24 hours |
|
|
34
|
+
| issuer, audience, expiry, not-before | the registration and the clock, with a clock skew of 5 seconds |
|
|
35
|
+
| the token type | an ID token or a refresh token presented as a bearer is refused (`typ` must be `Bearer`) |
|
|
36
|
+
| the roles | `realm_access.roles`, `resource_access.<client>.roles` of the mapped clients and the mapped flat claims; a claim of the wrong shape or past 128 roles refuses the token |
|
|
37
|
+
|
|
38
|
+
`verify` answers `{ kind: "ok", value: OidcPrincipal }` or `{ kind: "refused", code: "PASSPORT_ACCESS_TOKEN_INVALID", detail: { reason } }` (the code `@starci/auth` refuses with; the reason is for the log). A provider that cannot be asked and has nothing cached throws an `OidcError` of kind `unavailable`, which the app maps to a 503, never a 401: an identity-provider outage does not sign anyone out.
|
|
39
|
+
|
|
40
|
+
Endpoints a discovery document advertises are trusted only on the issuer's origin, over https, without credentials; `http:` is admitted only with `allowInsecureHttp` (a developer machine or a test double).
|
|
41
|
+
|
|
42
|
+
### The glue in an app
|
|
43
|
+
|
|
44
|
+
`integrations/keycloak` (slot `be.integrations.keycloak`) is declaration only, five files; no unit spec sits beside them (the proof is the integration spec of `src/tests/integration/keycloak/`, which drives an outage with `FakeOidcIssuer`, and the guard's own spec):
|
|
45
|
+
|
|
46
|
+
| file | holds |
|
|
47
|
+
|---|---|
|
|
48
|
+
| `keycloak.options.ts` | the typed registered client (`issuer`, `audience`, `clientId`, `clientSecret: Secret`, `callbackUri`, `postLogoutUri`, `timeoutMs`) |
|
|
49
|
+
| `keycloak.config.ts` | `parseKeycloakConfig(env: EnvSource)`: `env.url`, `env.string`, `env.secret`, `env.int`; no default address or credential |
|
|
50
|
+
| `keycloak.decorators.ts` | the token of the package's verifier (`OIDC_PRINCIPAL_VERIFIER`), which `domain/identity` provides under its own `ACCESS_TOKEN_VERIFIER` |
|
|
51
|
+
| `keycloak.registration.ts` | `keycloakModule(options, { isGlobal })` = `OidcModule.registerAsync` over `HTTP_CLIENT` and `CLOCK`, the `http` option adapting `HttpClient.request` with its correlation id |
|
|
52
|
+
| `index.ts` | what the rest of the app reads: the config parser, the module function, the token and the package's `PrincipalVerifier` and `OidcPrincipal` types |
|
|
53
|
+
|
|
54
|
+
`AuthGuard` of `domain/identity` reads the bearer token and asks the injected `PrincipalVerifier`, exactly as the lite guard asks `@starci/auth`'s: a verified principal is left on the request, a refusal is `IDENTITY_UNAUTHENTICATED`, and an `unavailable` error is not caught (the errors filter maps its kind to a 503).
|
|
55
|
+
|
|
56
|
+
## Front end
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
// modules/auth/client.ts: the one instance
|
|
60
|
+
export const auth = createOidcClient({ issuer: () => issuer(), clientId: "web", redirectUri: () => `${origin()}/auth/callback`, postLogoutRedirectUri: () => origin() })
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
`signIn()` redirects to the provider; the callback page calls `completeSignIn(window.location.href)`; `restore()` signs in again without a page after a reload (a `prompt=none` redirect, tried once); `accessToken()`, `renew()`, `headers()`, `subscribe`, `getSnapshot`, `watch` and `signOut()` have the shape of the `@starci/auth/client` session surface. The refresh token lives in memory with the access token and never in storage; a refresh ends the session only on a 401, a 403 or a 400 `invalid_grant`, and a 429, a 5xx or a lost connection is retried with backoff.
|
|
64
|
+
|
|
65
|
+
## Tests
|
|
66
|
+
|
|
67
|
+
`npm test` typechecks (including the assignability of the verifier and the client to the `@starci/auth` interfaces), lints, and runs the `node:test` specs under 90% line coverage. The suite needs `@starci/auth` built next to it (`file:../auth`).
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
const issuer = await FakeOidcIssuer.create({ audience: "api" })
|
|
71
|
+
const moduleRef = await Test.createTestingModule({ imports: [OidcModule.register(issuer.options({ callbackUri }))] }).compile()
|
|
72
|
+
const token = await issuer.mintAccessToken({ sub: "user-1", roles: ["member"] })
|
|
73
|
+
issuer.setDown(true) // the provider is unreachable: verification throws an unavailable error
|
|
74
|
+
```
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { createOidcClient } from "./oidc-client.js";
|
|
2
|
+
export type { OidcAuthClient, OidcClientOptions, OidcEvent, OidcListener, OidcSignedIn, OidcSnapshot, OidcStorage } from "./oidc-client.js";
|
|
3
|
+
export { sameSitePath } from "./pkce.js";
|
|
4
|
+
export { OIDC_CLIENT_CODES } from "../contracts/index.js";
|
|
5
|
+
export type { OidcClientOutcome, OidcFailureKind, OidcSessionUser } from "../contracts/index.js";
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { SessionEvent, SessionListener, SessionSnapshot } from "@starci/auth/client";
|
|
2
|
+
import type { OidcClientOutcome, OidcSessionUser } from "../contracts/index.js";
|
|
3
|
+
/** The event a listener hears: the same four as `AuthEvent` of `@starci/auth/client`. */
|
|
4
|
+
export type OidcEvent = SessionEvent;
|
|
5
|
+
/** Called on every sign-in, sign-out and token refresh with the id of the person (null when signed out). */
|
|
6
|
+
export type OidcListener = SessionListener;
|
|
7
|
+
/** What a view reads: loading until the first answer, then signed in or out. The object changes only when the session does. */
|
|
8
|
+
export type OidcSnapshot = SessionSnapshot<OidcSessionUser>;
|
|
9
|
+
/** The small part of `Storage` the client needs: the transaction of one redirect lives here (and a sign-in hint), never a token. */
|
|
10
|
+
export interface OidcStorage {
|
|
11
|
+
getItem(key: string): string | null;
|
|
12
|
+
setItem(key: string, value: string): void;
|
|
13
|
+
removeItem(key: string): void;
|
|
14
|
+
}
|
|
15
|
+
/** The options of the client. */
|
|
16
|
+
export interface OidcClientOptions {
|
|
17
|
+
/** The issuer (the realm address). */
|
|
18
|
+
readonly issuer: string | (() => string);
|
|
19
|
+
/** The registered public client. */
|
|
20
|
+
readonly clientId: string;
|
|
21
|
+
/** The exact registered redirect address, the page that calls `completeSignIn`. */
|
|
22
|
+
readonly redirectUri: string | (() => string);
|
|
23
|
+
/** The exact registered address the provider returns to after logout; without it sign-out only forgets the session here. */
|
|
24
|
+
readonly postLogoutRedirectUri?: string | (() => string) | undefined;
|
|
25
|
+
/** The requested scope (`openid`). */
|
|
26
|
+
readonly scope?: string | undefined;
|
|
27
|
+
/** The fetch to use (the global one when absent). */
|
|
28
|
+
readonly fetch?: typeof fetch | undefined;
|
|
29
|
+
/** Where the transaction of a redirect is kept: `sessionStorage` when the platform has it, else memory. */
|
|
30
|
+
readonly storage?: OidcStorage | undefined;
|
|
31
|
+
/** Takes the browser to an address (`location.assign`). */
|
|
32
|
+
readonly navigate?: ((url: string) => void) | undefined;
|
|
33
|
+
/** Refresh this long before the token really expires, so a request never carries a dying one (15 seconds). */
|
|
34
|
+
readonly refreshAheadMs?: number | undefined;
|
|
35
|
+
/** Tries a refresh makes before it gives up on a busy or unreachable provider (4). */
|
|
36
|
+
readonly retryAttempts?: number | undefined;
|
|
37
|
+
/** The base of the linear backoff between tries, in milliseconds (400). */
|
|
38
|
+
readonly retryBaseMs?: number | undefined;
|
|
39
|
+
/** The clock in milliseconds (`Date.now`). */
|
|
40
|
+
readonly now?: (() => number) | undefined;
|
|
41
|
+
/** The pause between tries (a timer). */
|
|
42
|
+
readonly sleep?: ((ms: number) => Promise<void>) | undefined;
|
|
43
|
+
}
|
|
44
|
+
/** What a finished sign-in answers: the person and where the page was going. */
|
|
45
|
+
export interface OidcSignedIn {
|
|
46
|
+
readonly user: OidcSessionUser;
|
|
47
|
+
/** The same-site path `signIn` was asked to return to, or `/`. */
|
|
48
|
+
readonly returnTo: string;
|
|
49
|
+
}
|
|
50
|
+
/** The OIDC client of a front end: the access token lives in this closure's memory only, never in storage or a cookie. */
|
|
51
|
+
export interface OidcAuthClient {
|
|
52
|
+
/** Takes the browser to the provider's login page (authorization code flow, PKCE S256, state, nonce). `prompt: "none"` asks without showing a page. */
|
|
53
|
+
signIn(options?: {
|
|
54
|
+
readonly returnTo?: string;
|
|
55
|
+
readonly prompt?: "none" | "login";
|
|
56
|
+
}): Promise<OidcClientOutcome<true>>;
|
|
57
|
+
/** Finishes the flow on the redirect page: checks the state and the issuer, exchanges the code once and keeps the grant. */
|
|
58
|
+
completeSignIn(callbackUrl: string): Promise<OidcClientOutcome<OidcSignedIn>>;
|
|
59
|
+
/** Signs in again without a page when this browser signed in before and holds no session now (a reload): a `prompt=none` redirect, tried once. */
|
|
60
|
+
restore(): Promise<OidcClientOutcome<"redirected" | "skipped">>;
|
|
61
|
+
/** Forgets the session and takes the browser to the provider's logout address. */
|
|
62
|
+
signOut(): Promise<void>;
|
|
63
|
+
/** Refreshes with the refresh token held in memory. Overlapping calls share one refresh. `undefined` when it cannot be refreshed. */
|
|
64
|
+
renew(): Promise<string | undefined>;
|
|
65
|
+
/** The access token of the current session, refreshed when missing or about to expire; `undefined` when signed out. */
|
|
66
|
+
accessToken(): Promise<string | undefined>;
|
|
67
|
+
/** The held access token without any request. */
|
|
68
|
+
peek(): string | undefined;
|
|
69
|
+
/** The signed-in person, or null when signed out. */
|
|
70
|
+
user(): Promise<OidcSessionUser | null>;
|
|
71
|
+
/** The `Authorization` header of the current session (an empty object when signed out). */
|
|
72
|
+
headers(): Promise<Record<string, string>>;
|
|
73
|
+
/** Forgets the session. */
|
|
74
|
+
drop(): void;
|
|
75
|
+
/** Listens to auth events; the first answer comes from a refresh. Answers the function that stops listening. */
|
|
76
|
+
subscribe(listener: OidcListener): () => void;
|
|
77
|
+
/** The current snapshot (stable between changes). */
|
|
78
|
+
getSnapshot: () => OidcSnapshot;
|
|
79
|
+
/** The snapshot for server rendering: always loading. */
|
|
80
|
+
getServerSnapshot: () => OidcSnapshot;
|
|
81
|
+
/** Calls `onChange` when the snapshot changes (the shape `useSyncExternalStore` takes). */
|
|
82
|
+
watch: (onChange: () => void) => () => void;
|
|
83
|
+
}
|
|
84
|
+
/** Creates the one OIDC client of a front end (one instance, like the event stream). */
|
|
85
|
+
export declare const createOidcClient: (options: OidcClientOptions) => OidcAuthClient;
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import { createSessionStore, createSingleFlight } from "@starci/auth/client";
|
|
2
|
+
import { OIDC_CLIENT_CODES } from "../contracts/index.js";
|
|
3
|
+
import { challengeOf, claimsOf, randomText, sameSitePath, textOf } from "./pkce.js";
|
|
4
|
+
const TRANSACTION_KEY = "starci.oidc.tx.";
|
|
5
|
+
const HINT_KEY = "starci.oidc.hint";
|
|
6
|
+
const SILENT_KEY = "starci.oidc.silent";
|
|
7
|
+
const TRANSACTION_TTL_MS = 600_000;
|
|
8
|
+
const MILLISECONDS = 1000;
|
|
9
|
+
/** The answers of a prompt-less request that mean "sign in on a page", not a failure. */
|
|
10
|
+
const SILENT_ANSWERS = ["login_required", "interaction_required", "consent_required", "account_selection_required"];
|
|
11
|
+
const failure = (kind, code) => ({ success: false, message: kind, error: code });
|
|
12
|
+
const success = (data) => ({ success: true, message: "ok", data });
|
|
13
|
+
const recordOf = (value) => (typeof value === "object" && value !== null ? Object.fromEntries(Object.entries(value)) : {});
|
|
14
|
+
const grantOf = (body) => {
|
|
15
|
+
const fields = recordOf(body);
|
|
16
|
+
const { access_token: accessToken, expires_in: expiresIn, refresh_token: refreshToken, id_token: idToken, token_type: type } = fields;
|
|
17
|
+
if (typeof accessToken !== "string" || accessToken === "" || typeof expiresIn !== "number" || typeof type !== "string" || type.toLowerCase() !== "bearer")
|
|
18
|
+
return undefined;
|
|
19
|
+
return { accessToken, expiresIn, refreshToken: textOf(refreshToken), idToken: textOf(idToken) };
|
|
20
|
+
};
|
|
21
|
+
const memoryStorage = () => {
|
|
22
|
+
const held = new Map();
|
|
23
|
+
return {
|
|
24
|
+
getItem: (key) => held.get(key) ?? null,
|
|
25
|
+
setItem: (key, value) => {
|
|
26
|
+
held.set(key, value);
|
|
27
|
+
},
|
|
28
|
+
removeItem: (key) => {
|
|
29
|
+
held.delete(key);
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
};
|
|
33
|
+
const isStorage = (value) => typeof value === "object" && value !== null && "getItem" in value && "setItem" in value && "removeItem" in value;
|
|
34
|
+
const platformStorage = () => {
|
|
35
|
+
try {
|
|
36
|
+
const candidate = Reflect.get(globalThis, "sessionStorage");
|
|
37
|
+
return isStorage(candidate) ? candidate : memoryStorage();
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return memoryStorage();
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
const platformNavigate = (url) => {
|
|
44
|
+
const location = Reflect.get(globalThis, "location");
|
|
45
|
+
const assign = typeof location === "object" && location !== null ? Reflect.get(location, "assign") : undefined;
|
|
46
|
+
if (typeof assign === "function" && typeof location === "object")
|
|
47
|
+
Reflect.apply(assign, location, [url]);
|
|
48
|
+
};
|
|
49
|
+
const timerPause = (ms) => new Promise((resolve) => {
|
|
50
|
+
const timer = setTimeout(() => {
|
|
51
|
+
clearTimeout(timer);
|
|
52
|
+
resolve();
|
|
53
|
+
}, ms);
|
|
54
|
+
});
|
|
55
|
+
const parseTransaction = (raw) => {
|
|
56
|
+
if (raw === null)
|
|
57
|
+
return undefined;
|
|
58
|
+
try {
|
|
59
|
+
const fields = recordOf(JSON.parse(raw));
|
|
60
|
+
const { verifier, nonce, returnTo, at } = fields;
|
|
61
|
+
return typeof verifier === "string" && typeof nonce === "string" && typeof returnTo === "string" && typeof at === "number" ? { verifier, nonce, returnTo, at } : undefined;
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
/** Creates the one OIDC client of a front end (one instance, like the event stream). */
|
|
68
|
+
export const createOidcClient = (options) => {
|
|
69
|
+
const fetcher = options.fetch ?? ((input, init) => fetch(input, init));
|
|
70
|
+
const storage = options.storage ?? platformStorage();
|
|
71
|
+
const navigate = options.navigate ?? platformNavigate;
|
|
72
|
+
const refreshAheadMs = options.refreshAheadMs ?? 15_000;
|
|
73
|
+
const attemptsMax = options.retryAttempts ?? 4;
|
|
74
|
+
const retryBaseMs = options.retryBaseMs ?? 400;
|
|
75
|
+
const now = options.now ?? Date.now;
|
|
76
|
+
const pause = options.sleep ?? timerPause;
|
|
77
|
+
const scope = options.scope ?? "openid";
|
|
78
|
+
const valueOf = (value) => (typeof value === "string" ? value : value());
|
|
79
|
+
let token;
|
|
80
|
+
let refreshToken;
|
|
81
|
+
let idToken;
|
|
82
|
+
let expiresAt = 0;
|
|
83
|
+
let announced = false;
|
|
84
|
+
let user = null;
|
|
85
|
+
let discovered;
|
|
86
|
+
// The snapshot, the listeners and the watchers are the session store of @starci/auth/client, the one every provider's client shares.
|
|
87
|
+
const store = createSessionStore({
|
|
88
|
+
announced: () => announced,
|
|
89
|
+
announce: () => {
|
|
90
|
+
announced = true;
|
|
91
|
+
},
|
|
92
|
+
held: () => token !== undefined,
|
|
93
|
+
userId: () => user?.id ?? null,
|
|
94
|
+
renew: () => renew(),
|
|
95
|
+
refreshing: () => flight.running(),
|
|
96
|
+
});
|
|
97
|
+
const userOf = (grant) => {
|
|
98
|
+
const { sub, email } = claimsOf(grant.idToken ?? grant.accessToken);
|
|
99
|
+
return typeof sub === "string" ? { id: sub, email: typeof email === "string" ? email : "" } : null;
|
|
100
|
+
};
|
|
101
|
+
const keep = (grant, event) => {
|
|
102
|
+
token = grant.accessToken;
|
|
103
|
+
// A refresh answers a new refresh token when the provider rotates; one that does not leaves the held one valid.
|
|
104
|
+
refreshToken = grant.refreshToken ?? refreshToken;
|
|
105
|
+
idToken = grant.idToken ?? idToken;
|
|
106
|
+
expiresAt = now() + grant.expiresIn * MILLISECONDS;
|
|
107
|
+
announced = true;
|
|
108
|
+
user = userOf(grant);
|
|
109
|
+
storage.setItem(HINT_KEY, "1");
|
|
110
|
+
store.signedIn(user);
|
|
111
|
+
store.emit(event, user?.id ?? null);
|
|
112
|
+
};
|
|
113
|
+
const drop = () => {
|
|
114
|
+
const had = token !== undefined;
|
|
115
|
+
token = undefined;
|
|
116
|
+
refreshToken = undefined;
|
|
117
|
+
idToken = undefined;
|
|
118
|
+
expiresAt = 0;
|
|
119
|
+
announced = true;
|
|
120
|
+
user = null;
|
|
121
|
+
store.signedOut();
|
|
122
|
+
if (had)
|
|
123
|
+
store.emit("SIGNED_OUT", null);
|
|
124
|
+
};
|
|
125
|
+
function refreshEvent() {
|
|
126
|
+
if (!announced)
|
|
127
|
+
return "INITIAL_SESSION";
|
|
128
|
+
return token === undefined ? "SIGNED_IN" : "TOKEN_REFRESHED";
|
|
129
|
+
}
|
|
130
|
+
const discover = () => {
|
|
131
|
+
discovered ??= (async () => {
|
|
132
|
+
const response = await fetcher(`${valueOf(options.issuer).replace(/\/$/u, "")}/.well-known/openid-configuration`, { headers: { accept: "application/json" } });
|
|
133
|
+
const document = recordOf(await response.json().catch(() => undefined));
|
|
134
|
+
const { authorization_endpoint: authorization, token_endpoint: tokenEndpoint, end_session_endpoint: endSession, issuer } = document;
|
|
135
|
+
if (!response.ok || issuer !== valueOf(options.issuer) || typeof authorization !== "string" || typeof tokenEndpoint !== "string")
|
|
136
|
+
throw new Error(OIDC_CLIENT_CODES.Provider);
|
|
137
|
+
return { authorization, token: tokenEndpoint, endSession: typeof endSession === "string" ? endSession : undefined };
|
|
138
|
+
})().catch((cause) => {
|
|
139
|
+
discovered = undefined;
|
|
140
|
+
throw cause;
|
|
141
|
+
});
|
|
142
|
+
return discovered;
|
|
143
|
+
};
|
|
144
|
+
const tokenRequest = async (form) => {
|
|
145
|
+
const { token: endpoint } = await discover();
|
|
146
|
+
const response = await fetcher(endpoint, {
|
|
147
|
+
method: "POST",
|
|
148
|
+
headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
|
|
149
|
+
body: new URLSearchParams({ client_id: options.clientId, ...form }).toString(),
|
|
150
|
+
});
|
|
151
|
+
return { status: response.status, body: await response.json().catch(() => undefined) };
|
|
152
|
+
};
|
|
153
|
+
/** True when the answer to a refresh says the session is over: the provider refused the refresh token (401, 403, or 400 `invalid_grant`). A 429, a 5xx or a lost connection never ends a session. */
|
|
154
|
+
const endsSession = (status, body) => status === 401 || status === 403 || (status === 400 && recordOf(body).error === "invalid_grant");
|
|
155
|
+
const requestRefresh = async (attempt = 1) => {
|
|
156
|
+
const retry = async () => {
|
|
157
|
+
await pause(retryBaseMs * attempt);
|
|
158
|
+
return requestRefresh(attempt + 1);
|
|
159
|
+
};
|
|
160
|
+
const held = refreshToken;
|
|
161
|
+
if (held === undefined) {
|
|
162
|
+
drop();
|
|
163
|
+
return undefined;
|
|
164
|
+
}
|
|
165
|
+
try {
|
|
166
|
+
const answer = await tokenRequest({ grant_type: "refresh_token", refresh_token: held });
|
|
167
|
+
if (answer.status === 200) {
|
|
168
|
+
const grant = grantOf(answer.body);
|
|
169
|
+
if (grant === undefined)
|
|
170
|
+
return undefined;
|
|
171
|
+
keep(grant, refreshEvent());
|
|
172
|
+
return grant.accessToken;
|
|
173
|
+
}
|
|
174
|
+
if (endsSession(answer.status, answer.body)) {
|
|
175
|
+
storage.removeItem(HINT_KEY);
|
|
176
|
+
drop();
|
|
177
|
+
return undefined;
|
|
178
|
+
}
|
|
179
|
+
if ((answer.status === 429 || answer.status >= 500) && attempt < attemptsMax)
|
|
180
|
+
return await retry();
|
|
181
|
+
return undefined;
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
return attempt < attemptsMax ? retry() : undefined;
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
// Many requests answered 401 together refresh once: the others await the one in flight.
|
|
188
|
+
const flight = createSingleFlight(() => requestRefresh());
|
|
189
|
+
const renew = () => flight.run();
|
|
190
|
+
const accessToken = async () => (token !== undefined && expiresAt - refreshAheadMs > now() ? token : renew());
|
|
191
|
+
const signIn = async (input = {}) => {
|
|
192
|
+
try {
|
|
193
|
+
const { authorization } = await discover();
|
|
194
|
+
const state = randomText();
|
|
195
|
+
const nonce = randomText();
|
|
196
|
+
const verifier = randomText();
|
|
197
|
+
const transaction = { verifier, nonce, returnTo: sameSitePath(input.returnTo, "/"), at: now() };
|
|
198
|
+
storage.setItem(TRANSACTION_KEY + state, JSON.stringify(transaction));
|
|
199
|
+
const url = new URL(authorization);
|
|
200
|
+
url.search = new URLSearchParams({
|
|
201
|
+
response_type: "code",
|
|
202
|
+
client_id: options.clientId,
|
|
203
|
+
redirect_uri: valueOf(options.redirectUri),
|
|
204
|
+
scope,
|
|
205
|
+
state,
|
|
206
|
+
nonce,
|
|
207
|
+
code_challenge: await challengeOf(verifier),
|
|
208
|
+
code_challenge_method: "S256",
|
|
209
|
+
...(input.prompt === undefined ? {} : { prompt: input.prompt }),
|
|
210
|
+
}).toString();
|
|
211
|
+
navigate(url.toString());
|
|
212
|
+
return success(true);
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
return failure("unavailable", OIDC_CLIENT_CODES.Transport);
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
const completeSignIn = async (callbackUrl) => {
|
|
219
|
+
const query = new URL(callbackUrl).searchParams;
|
|
220
|
+
const state = query.get("state") ?? "";
|
|
221
|
+
const key = TRANSACTION_KEY + state;
|
|
222
|
+
const transaction = state === "" ? undefined : parseTransaction(storage.getItem(key));
|
|
223
|
+
storage.removeItem(key);
|
|
224
|
+
storage.removeItem(SILENT_KEY);
|
|
225
|
+
if (transaction === undefined || now() - transaction.at > TRANSACTION_TTL_MS)
|
|
226
|
+
return failure("invalid", OIDC_CLIENT_CODES.State);
|
|
227
|
+
const refusal = query.get("error");
|
|
228
|
+
if (refusal !== null) {
|
|
229
|
+
if (SILENT_ANSWERS.includes(refusal))
|
|
230
|
+
storage.removeItem(HINT_KEY);
|
|
231
|
+
drop();
|
|
232
|
+
return failure("refused", refusal);
|
|
233
|
+
}
|
|
234
|
+
const issuer = query.get("iss");
|
|
235
|
+
if (issuer !== null && issuer !== valueOf(options.issuer))
|
|
236
|
+
return failure("invalid", OIDC_CLIENT_CODES.Issuer);
|
|
237
|
+
const code = query.get("code");
|
|
238
|
+
if (code === null || code === "")
|
|
239
|
+
return failure("invalid", OIDC_CLIENT_CODES.Token);
|
|
240
|
+
try {
|
|
241
|
+
const answer = await tokenRequest({ grant_type: "authorization_code", code, redirect_uri: valueOf(options.redirectUri), code_verifier: transaction.verifier });
|
|
242
|
+
const grant = answer.status === 200 ? grantOf(answer.body) : undefined;
|
|
243
|
+
if (grant === undefined)
|
|
244
|
+
return failure(answer.status >= 500 ? "unavailable" : "refused", answer.status >= 500 ? OIDC_CLIENT_CODES.Transport : OIDC_CLIENT_CODES.Provider);
|
|
245
|
+
if (grant.idToken !== undefined && claimsOf(grant.idToken).nonce !== transaction.nonce)
|
|
246
|
+
return failure("invalid", OIDC_CLIENT_CODES.Nonce);
|
|
247
|
+
keep(grant, "SIGNED_IN");
|
|
248
|
+
return user === null ? failure("invalid", OIDC_CLIENT_CODES.Token) : success({ user, returnTo: transaction.returnTo });
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
return failure("unavailable", OIDC_CLIENT_CODES.Transport);
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
const restore = async () => {
|
|
255
|
+
if (token !== undefined || storage.getItem(HINT_KEY) !== "1" || storage.getItem(SILENT_KEY) === "1")
|
|
256
|
+
return success("skipped");
|
|
257
|
+
storage.setItem(SILENT_KEY, "1");
|
|
258
|
+
const started = await signIn({ prompt: "none", returnTo: currentPath() });
|
|
259
|
+
if (started.success)
|
|
260
|
+
return success("redirected");
|
|
261
|
+
storage.removeItem(SILENT_KEY);
|
|
262
|
+
return started;
|
|
263
|
+
};
|
|
264
|
+
const currentPath = () => {
|
|
265
|
+
const location = Reflect.get(globalThis, "location");
|
|
266
|
+
const pathname = typeof location === "object" && location !== null ? Reflect.get(location, "pathname") : undefined;
|
|
267
|
+
return typeof pathname === "string" ? pathname : "/";
|
|
268
|
+
};
|
|
269
|
+
const signOut = async () => {
|
|
270
|
+
const hint = idToken;
|
|
271
|
+
const target = options.postLogoutRedirectUri;
|
|
272
|
+
storage.removeItem(HINT_KEY);
|
|
273
|
+
const endpoint = await discover().then((found) => found.endSession, () => undefined);
|
|
274
|
+
drop();
|
|
275
|
+
if (endpoint === undefined || target === undefined)
|
|
276
|
+
return;
|
|
277
|
+
const url = new URL(endpoint);
|
|
278
|
+
url.search = new URLSearchParams({
|
|
279
|
+
client_id: options.clientId,
|
|
280
|
+
post_logout_redirect_uri: valueOf(target),
|
|
281
|
+
...(hint === undefined ? {} : { id_token_hint: hint }),
|
|
282
|
+
}).toString();
|
|
283
|
+
navigate(url.toString());
|
|
284
|
+
};
|
|
285
|
+
return {
|
|
286
|
+
signIn,
|
|
287
|
+
completeSignIn,
|
|
288
|
+
restore,
|
|
289
|
+
signOut,
|
|
290
|
+
renew,
|
|
291
|
+
accessToken,
|
|
292
|
+
peek: () => token,
|
|
293
|
+
user: async () => ((await accessToken()) === undefined ? null : user),
|
|
294
|
+
headers: async () => {
|
|
295
|
+
const held = await accessToken();
|
|
296
|
+
return held === undefined ? {} : { authorization: `Bearer ${held}` };
|
|
297
|
+
},
|
|
298
|
+
drop,
|
|
299
|
+
subscribe: store.subscribe,
|
|
300
|
+
getSnapshot: store.getSnapshot,
|
|
301
|
+
getServerSnapshot: store.getServerSnapshot,
|
|
302
|
+
watch: store.watch,
|
|
303
|
+
};
|
|
304
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** base64url of bytes, without padding. */
|
|
2
|
+
export declare const bytesToBase64Url: (bytes: Uint8Array) => string;
|
|
3
|
+
/** 256 unpredictable bits as base64url: a state, a nonce or a PKCE verifier (43 characters, inside the 43 to 128 RFC 7636 asks). */
|
|
4
|
+
export declare const randomText: () => string;
|
|
5
|
+
/** The S256 challenge of a PKCE verifier: base64url(SHA-256(verifier)). */
|
|
6
|
+
export declare const challengeOf: (verifier: string) => Promise<string>;
|
|
7
|
+
/** Non-empty text, else undefined. */
|
|
8
|
+
export declare const textOf: (value: unknown) => string | undefined;
|
|
9
|
+
/**
|
|
10
|
+
* The claims of a token read WITHOUT checking its signature. For display only (who is signed in): the front end trusts no claim for a
|
|
11
|
+
* decision, the back end verifies every token it is sent.
|
|
12
|
+
*/
|
|
13
|
+
export declare const claimsOf: (token: string) => Readonly<Record<string, unknown>>;
|
|
14
|
+
/** A same-site path (one leading slash, no line break), else the fallback path: a return address never leaves the site. */
|
|
15
|
+
export declare const sameSitePath: (value: unknown, fallback: string) => string;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** base64url of bytes, without padding. */
|
|
2
|
+
export const bytesToBase64Url = (bytes) => btoa(String.fromCharCode(...bytes)).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
|
|
3
|
+
/** 256 unpredictable bits as base64url: a state, a nonce or a PKCE verifier (43 characters, inside the 43 to 128 RFC 7636 asks). */
|
|
4
|
+
export const randomText = () => bytesToBase64Url(crypto.getRandomValues(new Uint8Array(32)));
|
|
5
|
+
/** The S256 challenge of a PKCE verifier: base64url(SHA-256(verifier)). */
|
|
6
|
+
export const challengeOf = async (verifier) => bytesToBase64Url(new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))));
|
|
7
|
+
/** Non-empty text, else undefined. */
|
|
8
|
+
export const textOf = (value) => (typeof value === "string" && value !== "" ? value : undefined);
|
|
9
|
+
/**
|
|
10
|
+
* The claims of a token read WITHOUT checking its signature. For display only (who is signed in): the front end trusts no claim for a
|
|
11
|
+
* decision, the back end verifies every token it is sent.
|
|
12
|
+
*/
|
|
13
|
+
export const claimsOf = (token) => {
|
|
14
|
+
const payload = token.split(".")[1];
|
|
15
|
+
if (payload === undefined)
|
|
16
|
+
return {};
|
|
17
|
+
try {
|
|
18
|
+
const text = new TextDecoder().decode(Uint8Array.from(atob(payload.replaceAll("-", "+").replaceAll("_", "/")), (char) => char.charCodeAt(0)));
|
|
19
|
+
const parsed = JSON.parse(text);
|
|
20
|
+
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? Object.fromEntries(Object.entries(parsed)) : {};
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return {};
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
const SAME_SITE_PATH = /^\/(?![/\\])/u;
|
|
27
|
+
const LINE_BREAK = /[\r\n]/u;
|
|
28
|
+
/** A same-site path (one leading slash, no line break), else the fallback path: a return address never leaves the site. */
|
|
29
|
+
export const sameSitePath = (value, fallback) => typeof value === "string" && SAME_SITE_PATH.test(value) && !LINE_BREAK.test(value) ? value : fallback;
|