@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,47 @@
|
|
|
1
|
+
import type { HttpRequest } from "../http.js";
|
|
2
|
+
import type { AuthClock, AuthIds } from "../ports.js";
|
|
3
|
+
import type { AuthTokens } from "../tokens.js";
|
|
4
|
+
/** How long one provider round trip may take, in milliseconds. */
|
|
5
|
+
export declare const OAUTH_STATE_TTL_MS = 600000;
|
|
6
|
+
/** The state cookie of a provider when its options name none: `auth_<id>_state` (Google's is `auth_google_state`). */
|
|
7
|
+
export declare const oauthStateCookie: (provider: string) => string;
|
|
8
|
+
/** Where one provider's round trip is kept. */
|
|
9
|
+
export interface OAuthStateOptions {
|
|
10
|
+
/** The provider id, signed into the state as its purpose (`p`), so a state of one provider never passes another's callback. */
|
|
11
|
+
readonly provider: string;
|
|
12
|
+
/** The cookie that carries the round trip. */
|
|
13
|
+
readonly cookie: string;
|
|
14
|
+
/** The only path the cookie is sent to: the provider's door (`/auth/<id>`), which its callback sits under. */
|
|
15
|
+
readonly path: string;
|
|
16
|
+
/** The origin of the web app: an absolute return address is kept only on it. */
|
|
17
|
+
readonly returnOrigin: string;
|
|
18
|
+
}
|
|
19
|
+
/** What a state store needs. */
|
|
20
|
+
export interface OAuthStateDeps {
|
|
21
|
+
readonly tokens: AuthTokens;
|
|
22
|
+
readonly clock: AuthClock;
|
|
23
|
+
readonly ids: AuthIds;
|
|
24
|
+
readonly state: OAuthStateOptions;
|
|
25
|
+
}
|
|
26
|
+
type StoreDone = (error: Error | null, handle?: string) => void;
|
|
27
|
+
type VerifyDone = (error: Error | null, verifier?: unknown, context?: unknown) => void;
|
|
28
|
+
/**
|
|
29
|
+
* The state store passport-oauth2 calls for `state` and PKCE without a server session, for any provider: the handle (echoed as
|
|
30
|
+
* `state`), the code verifier and the landing path travel in a signed, httpOnly cookie that only the provider's door path receives,
|
|
31
|
+
* and the handle is compared on return. The landing path is a same-site path only, so the callback can never redirect off the web
|
|
32
|
+
* origin. The signed value is `{ w: handle, u: verifier, r: landing, e: expiry, p: provider }` under the HMAC key `AuthTokens.signState`
|
|
33
|
+
* derives, so Google's cookie is the one 0.5.0 wrote, byte for byte in shape.
|
|
34
|
+
*/
|
|
35
|
+
export declare class OAuthStateClient {
|
|
36
|
+
#private;
|
|
37
|
+
constructor(deps: OAuthStateDeps);
|
|
38
|
+
/** The name of the state cookie. */
|
|
39
|
+
get cookieName(): string;
|
|
40
|
+
/** The provider this store keeps round trips of. */
|
|
41
|
+
get provider(): string;
|
|
42
|
+
/** Called by passport-oauth2 on the outbound leg (the five-argument PKCE form). */
|
|
43
|
+
store(req: HttpRequest, verifier: string, _state: unknown, _meta: unknown, done: StoreDone): void;
|
|
44
|
+
/** Called by passport-oauth2 on the return leg (the meta form, since this method takes four arguments): the cookie must be ours, unexpired, of this provider and carry the same handle as `state`. */
|
|
45
|
+
verify(req: HttpRequest, providedState: string, _meta: unknown, done: VerifyDone): void;
|
|
46
|
+
}
|
|
47
|
+
export {};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { cookieValue, sameSitePath, sameValue } from "../primitives.js";
|
|
2
|
+
/** How long one provider round trip may take, in milliseconds. */
|
|
3
|
+
export const OAUTH_STATE_TTL_MS = 600_000;
|
|
4
|
+
/** The state cookie of a provider when its options name none: `auth_<id>_state` (Google's is `auth_google_state`). */
|
|
5
|
+
export const oauthStateCookie = (provider) => `auth_${provider}_state`;
|
|
6
|
+
/**
|
|
7
|
+
* The state store passport-oauth2 calls for `state` and PKCE without a server session, for any provider: the handle (echoed as
|
|
8
|
+
* `state`), the code verifier and the landing path travel in a signed, httpOnly cookie that only the provider's door path receives,
|
|
9
|
+
* and the handle is compared on return. The landing path is a same-site path only, so the callback can never redirect off the web
|
|
10
|
+
* origin. The signed value is `{ w: handle, u: verifier, r: landing, e: expiry, p: provider }` under the HMAC key `AuthTokens.signState`
|
|
11
|
+
* derives, so Google's cookie is the one 0.5.0 wrote, byte for byte in shape.
|
|
12
|
+
*/
|
|
13
|
+
export class OAuthStateClient {
|
|
14
|
+
#tokens;
|
|
15
|
+
#clock;
|
|
16
|
+
#ids;
|
|
17
|
+
#state;
|
|
18
|
+
constructor(deps) {
|
|
19
|
+
this.#tokens = deps.tokens;
|
|
20
|
+
this.#clock = deps.clock;
|
|
21
|
+
this.#ids = deps.ids;
|
|
22
|
+
this.#state = deps.state;
|
|
23
|
+
}
|
|
24
|
+
/** The name of the state cookie. */
|
|
25
|
+
get cookieName() {
|
|
26
|
+
return this.#state.cookie;
|
|
27
|
+
}
|
|
28
|
+
/** The provider this store keeps round trips of. */
|
|
29
|
+
get provider() {
|
|
30
|
+
return this.#state.provider;
|
|
31
|
+
}
|
|
32
|
+
/** Called by passport-oauth2 on the outbound leg (the five-argument PKCE form). */
|
|
33
|
+
store(req, verifier, _state, _meta, done) {
|
|
34
|
+
const handle = this.#ids.next();
|
|
35
|
+
const signed = this.#tokens.signState({
|
|
36
|
+
w: handle,
|
|
37
|
+
u: verifier,
|
|
38
|
+
r: this.#landingOf(req),
|
|
39
|
+
e: this.#clock.now().getTime() + OAUTH_STATE_TTL_MS,
|
|
40
|
+
p: this.#state.provider,
|
|
41
|
+
});
|
|
42
|
+
req.res?.cookie(this.#state.cookie, signed, {
|
|
43
|
+
httpOnly: true,
|
|
44
|
+
secure: true,
|
|
45
|
+
sameSite: "lax",
|
|
46
|
+
path: this.#state.path,
|
|
47
|
+
maxAge: OAUTH_STATE_TTL_MS,
|
|
48
|
+
});
|
|
49
|
+
done(null, handle);
|
|
50
|
+
}
|
|
51
|
+
/** The same-site path to land on: the requested return address when it is a path, or an absolute address on the web origin; else the root. */
|
|
52
|
+
#landingOf(req) {
|
|
53
|
+
const { returnTo, next } = req.query ?? {};
|
|
54
|
+
const wanted = typeof returnTo === "string" ? returnTo : next;
|
|
55
|
+
if (typeof wanted !== "string")
|
|
56
|
+
return "/";
|
|
57
|
+
if (!URL.canParse(wanted))
|
|
58
|
+
return sameSitePath(wanted, "/");
|
|
59
|
+
const url = new URL(wanted);
|
|
60
|
+
return url.origin === this.#state.returnOrigin ? sameSitePath(`${url.pathname}${url.search}`, "/") : "/";
|
|
61
|
+
}
|
|
62
|
+
/** Called by passport-oauth2 on the return leg (the meta form, since this method takes four arguments): the cookie must be ours, unexpired, of this provider and carry the same handle as `state`. */
|
|
63
|
+
verify(req, providedState, _meta, done) {
|
|
64
|
+
const header = req.headers.cookie;
|
|
65
|
+
const raw = cookieValue(Array.isArray(header) ? header.join("; ") : (header ?? ""), this.#state.cookie);
|
|
66
|
+
req.res?.clearCookie(this.#state.cookie, { httpOnly: true, secure: true, sameSite: "lax", path: this.#state.path });
|
|
67
|
+
const state = raw === "" ? null : this.#tokens.verifyState(raw, this.#clock.now());
|
|
68
|
+
if (state?.p !== this.#state.provider || typeof providedState !== "string" || !sameValue(state.w, providedState)) {
|
|
69
|
+
done(null, false, { message: "state" });
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
done(null, state.u, { next: sameSitePath(state.r, "/") });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { Strategy } from "passport";
|
|
2
|
+
import type { AuthErrorCode } from "../../contracts/index.js";
|
|
3
|
+
import type { AuthAuthenticator, AuthSubject, ProviderProfile } from "../ports.js";
|
|
4
|
+
import type { OAuthStateClient } from "./oauth-state.js";
|
|
5
|
+
/** The codes a provider's doors refuse with: the provider is not configured, its round trip cannot be proved, or it refused the person. */
|
|
6
|
+
export interface ProviderCodes {
|
|
7
|
+
readonly unavailable: AuthErrorCode;
|
|
8
|
+
readonly stateInvalid: AuthErrorCode;
|
|
9
|
+
readonly rejected: AuthErrorCode;
|
|
10
|
+
}
|
|
11
|
+
/** The codes of every provider but Google, which keeps the codes 0.5.0 answered with. */
|
|
12
|
+
export declare const PROVIDER_CODES: ProviderCodes;
|
|
13
|
+
/** What the module hands a provider to build its passport strategy. */
|
|
14
|
+
export interface ProviderContext {
|
|
15
|
+
/** The app's sign-in decisions. */
|
|
16
|
+
readonly authenticator: AuthAuthenticator;
|
|
17
|
+
/** The state store of this provider's round trip (handle, PKCE verifier and landing path in a signed cookie). */
|
|
18
|
+
readonly state: OAuthStateClient;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* One way to sign in through a third party (Google, GitHub, a next one): the whole of it lives in one file that calls `defineProvider`,
|
|
22
|
+
* and an app lists it in `AuthModule.register({ providers: [...] })`. The module gives it a state store, builds its passport strategy,
|
|
23
|
+
* registers the strategy under `id`, and the doors run `/auth/<id>` and `/auth/<id>/callback` for it.
|
|
24
|
+
*/
|
|
25
|
+
export interface SignInProvider<TOptions = unknown, TRaw = unknown> {
|
|
26
|
+
/** The provider id: the passport strategy name, the state purpose and the door segment. Lower-case letters, digits and dashes. */
|
|
27
|
+
readonly id: string;
|
|
28
|
+
/** The provider's options (client id, secret, callback address), typed and parsed by the app. */
|
|
29
|
+
readonly options: TOptions;
|
|
30
|
+
/** The cookie that carries one round trip (`auth_<id>_state` unless the options name one). */
|
|
31
|
+
readonly stateCookie: string;
|
|
32
|
+
/** The path that cookie is sent to: the provider's door (`/auth/<id>`). */
|
|
33
|
+
readonly statePath: string;
|
|
34
|
+
/** Extra parameters of the consent redirect (Google's `prompt: select_account`). */
|
|
35
|
+
readonly authorizeParams: Readonly<Record<string, string>>;
|
|
36
|
+
/** The codes its doors refuse with. */
|
|
37
|
+
readonly codes: ProviderCodes;
|
|
38
|
+
/** The facts the app needs from what the provider answered; an account without a subject is refused. */
|
|
39
|
+
readonly profileOf: (raw: TRaw) => ProviderProfile;
|
|
40
|
+
/** Builds the passport strategy (`state` and PKCE through `context.state`); the module registers it with passport under `id`. */
|
|
41
|
+
readonly strategy: (context: ProviderContext) => Strategy;
|
|
42
|
+
}
|
|
43
|
+
/** Any provider, whatever its options and raw profile: what `AuthModule.register` lists. */
|
|
44
|
+
export type AnySignInProvider = SignInProvider<unknown, never>;
|
|
45
|
+
/** What `defineProvider` takes: the id, the options, the profile mapping and the strategy, with the rest defaulted. */
|
|
46
|
+
export interface ProviderDefinition<TOptions, TRaw> {
|
|
47
|
+
readonly id: string;
|
|
48
|
+
readonly options: TOptions;
|
|
49
|
+
readonly stateCookie?: string | undefined;
|
|
50
|
+
readonly authorizeParams?: Readonly<Record<string, string>> | undefined;
|
|
51
|
+
readonly codes?: ProviderCodes | undefined;
|
|
52
|
+
readonly profileOf: (raw: TRaw) => ProviderProfile;
|
|
53
|
+
readonly strategy: (context: ProviderContext) => Strategy;
|
|
54
|
+
}
|
|
55
|
+
/** A provider from its definition: the id is checked (it becomes a door path, a cookie name and a passport name), the state cookie, its path and the codes are defaulted. */
|
|
56
|
+
export declare const defineProvider: <TOptions, TRaw>(definition: ProviderDefinition<TOptions, TRaw>) => SignInProvider<TOptions, TRaw>;
|
|
57
|
+
/** Signs in the person a provider other than Google established, through the app's `signInWithProvider`; an app that has none refuses the provider. */
|
|
58
|
+
export declare const signInWithProvider: (authenticator: AuthAuthenticator, profile: ProviderProfile) => Promise<AuthSubject>;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { AUTH_ERROR_CODES, providerDoor } from "../../contracts/index.js";
|
|
2
|
+
import { AuthError } from "../errors.js";
|
|
3
|
+
import { oauthStateCookie } from "./oauth-state.js";
|
|
4
|
+
/** The codes of every provider but Google, which keeps the codes 0.5.0 answered with. */
|
|
5
|
+
export const PROVIDER_CODES = {
|
|
6
|
+
unavailable: AUTH_ERROR_CODES.ProviderUnavailable,
|
|
7
|
+
stateInvalid: AUTH_ERROR_CODES.ProviderStateInvalid,
|
|
8
|
+
rejected: AUTH_ERROR_CODES.ProviderRejected,
|
|
9
|
+
};
|
|
10
|
+
const PROVIDER_ID = /^[a-z][a-z0-9-]{0,31}$/u;
|
|
11
|
+
/** A provider from its definition: the id is checked (it becomes a door path, a cookie name and a passport name), the state cookie, its path and the codes are defaulted. */
|
|
12
|
+
export const defineProvider = (definition) => {
|
|
13
|
+
if (!PROVIDER_ID.test(definition.id))
|
|
14
|
+
throw new AuthError(AUTH_ERROR_CODES.ProvidersInvalid, { context: { reason: "a provider id is lower-case letters, digits and dashes", id: definition.id } });
|
|
15
|
+
return {
|
|
16
|
+
id: definition.id,
|
|
17
|
+
options: definition.options,
|
|
18
|
+
stateCookie: definition.stateCookie ?? oauthStateCookie(definition.id),
|
|
19
|
+
statePath: providerDoor(definition.id),
|
|
20
|
+
authorizeParams: definition.authorizeParams ?? {},
|
|
21
|
+
codes: definition.codes ?? PROVIDER_CODES,
|
|
22
|
+
profileOf: definition.profileOf,
|
|
23
|
+
strategy: definition.strategy,
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
/** Signs in the person a provider other than Google established, through the app's `signInWithProvider`; an app that has none refuses the provider. */
|
|
27
|
+
export const signInWithProvider = (authenticator, profile) => {
|
|
28
|
+
if (authenticator.signInWithProvider === undefined) {
|
|
29
|
+
return Promise.reject(new AuthError(AUTH_ERROR_CODES.ProviderUnavailable, { context: { provider: profile.provider, reason: "the authenticator has no signInWithProvider" } }));
|
|
30
|
+
}
|
|
31
|
+
return authenticator.signInWithProvider(profile);
|
|
32
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Strategy } from "passport";
|
|
2
|
+
import type { AuthOptions } from "../options.js";
|
|
3
|
+
import type { AuthAuthenticator, AuthClock, AuthIds } from "../ports.js";
|
|
4
|
+
import type { AuthTokens } from "../tokens.js";
|
|
5
|
+
import { OAuthStateClient } from "./oauth-state.js";
|
|
6
|
+
import type { AnySignInProvider } from "./provider.js";
|
|
7
|
+
/**
|
|
8
|
+
* The providers of one registration, in order: Google from the `google` option of 0.5.0 when it is set, then `providers`. Two
|
|
9
|
+
* providers with one id (Google both as the option and in the list) are a configuration error, never a silent pick.
|
|
10
|
+
*/
|
|
11
|
+
export declare const providersOf: (options: Pick<AuthOptions, "google" | "providers">) => readonly AnySignInProvider[];
|
|
12
|
+
/** One provider as the module runs it: its definition, its state store and its passport strategy (registered under its id). */
|
|
13
|
+
export interface ProviderEntry {
|
|
14
|
+
readonly provider: AnySignInProvider;
|
|
15
|
+
readonly state: OAuthStateClient;
|
|
16
|
+
readonly strategy: Strategy;
|
|
17
|
+
}
|
|
18
|
+
/** What building the providers needs. */
|
|
19
|
+
export interface ProviderRuntimeDeps {
|
|
20
|
+
readonly providers: readonly AnySignInProvider[];
|
|
21
|
+
readonly authenticator: AuthAuthenticator;
|
|
22
|
+
readonly tokens: AuthTokens;
|
|
23
|
+
readonly clock: AuthClock;
|
|
24
|
+
readonly ids: AuthIds;
|
|
25
|
+
readonly returnOrigin: string;
|
|
26
|
+
/** The Google state store the module already holds (`AUTH_GOOGLE_STATE`), reused for the Google provider. */
|
|
27
|
+
readonly googleState?: OAuthStateClient | undefined;
|
|
28
|
+
}
|
|
29
|
+
/** Builds each provider's state store and strategy and registers the strategy with passport under the provider id. */
|
|
30
|
+
export declare const buildProviders: (deps: ProviderRuntimeDeps) => ReadonlyMap<string, ProviderEntry>;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import passport from "passport";
|
|
2
|
+
import { AUTH_ERROR_CODES } from "../../contracts/index.js";
|
|
3
|
+
import { AuthError } from "../errors.js";
|
|
4
|
+
import { GOOGLE_PROVIDER_ID, google } from "./google.js";
|
|
5
|
+
import { OAuthStateClient } from "./oauth-state.js";
|
|
6
|
+
/**
|
|
7
|
+
* The providers of one registration, in order: Google from the `google` option of 0.5.0 when it is set, then `providers`. Two
|
|
8
|
+
* providers with one id (Google both as the option and in the list) are a configuration error, never a silent pick.
|
|
9
|
+
*/
|
|
10
|
+
export const providersOf = (options) => {
|
|
11
|
+
const listed = [...(options.google ? [google(options.google)] : []), ...(options.providers ?? [])];
|
|
12
|
+
const seen = new Set();
|
|
13
|
+
for (const provider of listed) {
|
|
14
|
+
if (seen.has(provider.id))
|
|
15
|
+
throw new AuthError(AUTH_ERROR_CODES.ProvidersInvalid, { context: { reason: "two providers share an id", id: provider.id } });
|
|
16
|
+
seen.add(provider.id);
|
|
17
|
+
}
|
|
18
|
+
return listed;
|
|
19
|
+
};
|
|
20
|
+
/** Builds each provider's state store and strategy and registers the strategy with passport under the provider id. */
|
|
21
|
+
export const buildProviders = (deps) => {
|
|
22
|
+
const entries = new Map();
|
|
23
|
+
for (const provider of deps.providers) {
|
|
24
|
+
const reuse = provider.id === GOOGLE_PROVIDER_ID && deps.googleState?.cookieName === provider.stateCookie ? deps.googleState : undefined;
|
|
25
|
+
const state = reuse ??
|
|
26
|
+
new OAuthStateClient({
|
|
27
|
+
tokens: deps.tokens,
|
|
28
|
+
clock: deps.clock,
|
|
29
|
+
ids: deps.ids,
|
|
30
|
+
state: { provider: provider.id, cookie: provider.stateCookie, path: provider.statePath, returnOrigin: deps.returnOrigin },
|
|
31
|
+
});
|
|
32
|
+
const strategy = provider.strategy({ authenticator: deps.authenticator, state });
|
|
33
|
+
passport.use(provider.id, strategy);
|
|
34
|
+
entries.set(provider.id, { provider, state, strategy });
|
|
35
|
+
}
|
|
36
|
+
return entries;
|
|
37
|
+
};
|
|
@@ -1,11 +1,7 @@
|
|
|
1
|
-
import { Strategy as GoogleStrategy } from "passport-google-oauth20";
|
|
2
|
-
import type { Profile } from "passport-google-oauth20";
|
|
3
1
|
import { Strategy as LocalStrategy } from "passport-local";
|
|
4
|
-
import type {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
/** The facts the app needs from a Google profile; an account without a subject is refused. */
|
|
8
|
-
export declare const googleProfileOf: (profile: Profile) => GoogleProfile;
|
|
2
|
+
import type { AuthAuthenticator, AuthSubject } from "./ports.js";
|
|
3
|
+
/** The Google strategy and its profile mapping live with the Google provider; named here as 0.5.0 did. */
|
|
4
|
+
export { PassportGoogleStrategy, googleProfileOf } from "./providers/google.js";
|
|
9
5
|
declare const PassportLocalStrategy_base: new (...args: [] | [options: import("passport-local").IStrategyOptionsWithRequest] | [options: import("passport-local").IStrategyOptions]) => LocalStrategy & {
|
|
10
6
|
validate(...args: any[]): unknown;
|
|
11
7
|
};
|
|
@@ -16,14 +12,3 @@ export declare class PassportLocalStrategy extends PassportLocalStrategy_base {
|
|
|
16
12
|
/** Called by passport with the posted pair; answers the person or throws the app's refusal. */
|
|
17
13
|
validate(email: string, password: string): Promise<AuthSubject>;
|
|
18
14
|
}
|
|
19
|
-
declare const PassportGoogleStrategy_base: new (...args: [options: import("passport-google-oauth20").StrategyOptionsWithRequest] | [options: import("passport-google-oauth20").StrategyOptions] | [options: import("passport-google-oauth20").StrategyOptions] | [options: import("passport-google-oauth20").StrategyOptionsWithRequest]) => GoogleStrategy & {
|
|
20
|
-
validate(...args: any[]): unknown;
|
|
21
|
-
};
|
|
22
|
-
/** The Google OAuth 2.0 strategy: authorization code with `state` and PKCE (S256), the round trip held in a signed cookie. */
|
|
23
|
-
export declare class PassportGoogleStrategy extends PassportGoogleStrategy_base {
|
|
24
|
-
#private;
|
|
25
|
-
constructor(google: GoogleOptions, authenticator: AuthAuthenticator, state: GoogleStateClient);
|
|
26
|
-
/** Called by passport with Google's profile; answers the person the app stands it for. */
|
|
27
|
-
validate(_accessToken: string, _refreshToken: string, profile: Profile): Promise<AuthSubject>;
|
|
28
|
-
}
|
|
29
|
-
export {};
|
|
@@ -1,25 +1,7 @@
|
|
|
1
1
|
import { PassportStrategy } from "@nestjs/passport";
|
|
2
|
-
import { Strategy as GoogleStrategy } from "passport-google-oauth20";
|
|
3
2
|
import { Strategy as LocalStrategy } from "passport-local";
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
import { revealed } from "./primitives.js";
|
|
7
|
-
const verifiedOf = (profile) => {
|
|
8
|
-
const claim = profile._json.email_verified;
|
|
9
|
-
return claim === true || claim === "true";
|
|
10
|
-
};
|
|
11
|
-
/** The facts the app needs from a Google profile; an account without a subject is refused. */
|
|
12
|
-
export const googleProfileOf = (profile) => {
|
|
13
|
-
if (profile.id === "")
|
|
14
|
-
throw new AuthError(AUTH_ERROR_CODES.GoogleRejected);
|
|
15
|
-
return {
|
|
16
|
-
subject: profile.id,
|
|
17
|
-
email: profile.emails?.[0]?.value ?? null,
|
|
18
|
-
emailVerified: verifiedOf(profile),
|
|
19
|
-
name: profile.displayName === "" ? null : profile.displayName,
|
|
20
|
-
picture: profile.photos?.[0]?.value ?? null,
|
|
21
|
-
};
|
|
22
|
-
};
|
|
3
|
+
/** The Google strategy and its profile mapping live with the Google provider; named here as 0.5.0 did. */
|
|
4
|
+
export { PassportGoogleStrategy, googleProfileOf } from "./providers/google.js";
|
|
23
5
|
/** The email and password strategy; the app decides whether the pair is right. */
|
|
24
6
|
export class PassportLocalStrategy extends PassportStrategy(LocalStrategy, "local") {
|
|
25
7
|
#authenticator;
|
|
@@ -32,24 +14,3 @@ export class PassportLocalStrategy extends PassportStrategy(LocalStrategy, "loca
|
|
|
32
14
|
return this.#authenticator.verifyPassword(email, password);
|
|
33
15
|
}
|
|
34
16
|
}
|
|
35
|
-
/** The Google OAuth 2.0 strategy: authorization code with `state` and PKCE (S256), the round trip held in a signed cookie. */
|
|
36
|
-
export class PassportGoogleStrategy extends PassportStrategy(GoogleStrategy, "google") {
|
|
37
|
-
#authenticator;
|
|
38
|
-
constructor(google, authenticator, state) {
|
|
39
|
-
super({
|
|
40
|
-
clientID: google.clientId,
|
|
41
|
-
clientSecret: revealed(google.clientSecret),
|
|
42
|
-
callbackURL: google.callbackUrl,
|
|
43
|
-
scope: ["openid", "email", "profile"],
|
|
44
|
-
state: true,
|
|
45
|
-
pkce: true,
|
|
46
|
-
});
|
|
47
|
-
this.#authenticator = authenticator;
|
|
48
|
-
// passport-oauth2 types describe only its session stores; the stateless PKCE store replaces the one it built.
|
|
49
|
-
Object.assign(this, { _stateStore: state });
|
|
50
|
-
}
|
|
51
|
-
/** Called by passport with Google's profile; answers the person the app stands it for. */
|
|
52
|
-
validate(_accessToken, _refreshToken, profile) {
|
|
53
|
-
return this.#authenticator.signInWithGoogle(googleProfileOf(profile));
|
|
54
|
-
}
|
|
55
|
-
}
|
|
@@ -3,7 +3,7 @@ import type { AuthClock, AuthPrincipal } from "./ports.js";
|
|
|
3
3
|
import type { AuthTokens } from "./tokens.js";
|
|
4
4
|
/**
|
|
5
5
|
* What `domain/identity` asks of an identity source: the principal of a bearer token, or a refusal. The lite edition's source is
|
|
6
|
-
* `AuthPrincipalVerifier`; the full edition's is the OIDC verifier of `@starci/auth
|
|
6
|
+
* `AuthPrincipalVerifier`; the full edition's is the OIDC verifier of `@starci/auth/oidc` behind this same interface, so guards,
|
|
7
7
|
* `@Public` and `@Credential` do not change between editions.
|
|
8
8
|
*/
|
|
9
9
|
export interface PrincipalVerifier {
|
package/dist/testing/index.d.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import type { AuthOptions, SigningKeyOptions } from "../server/options.js";
|
|
2
2
|
import type { AccountDirectory, AccountStanding, AuthClock, AuthIds, NewSession, RateCounter, RefreshTokenRecord, SessionStore, SessionTransaction } from "../server/ports.js";
|
|
3
|
+
/** The fake OIDC issuer of the full edition (Keycloak-shaped discovery, JWKS, token and admin endpoints), for the specs of an `integrations/keycloak` glue. */
|
|
4
|
+
export { FakeOidcIssuer } from "./oidc.js";
|
|
5
|
+
export type { FakeFailure, FakeOidcIssuerOptions, FakeRequest, FakeSeededUser, FakeTokenClaims, FakeUser, MintOverrides } from "./oidc.js";
|
|
3
6
|
/** A generated Ed25519 key pair as the PEM texts the options take. */
|
|
4
7
|
export interface TestKeyPair {
|
|
5
8
|
readonly privateKey: string;
|
package/dist/testing/index.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { generateKeyPairSync, randomUUID } from "node:crypto";
|
|
2
|
+
/** The fake OIDC issuer of the full edition (Keycloak-shaped discovery, JWKS, token and admin endpoints), for the specs of an `integrations/keycloak` glue. */
|
|
3
|
+
export { FakeOidcIssuer } from "./oidc.js";
|
|
2
4
|
/** Generates an Ed25519 key pair (PKCS#8 private PEM, SPKI public PEM). */
|
|
3
5
|
export const generateTestKeyPair = () => {
|
|
4
6
|
const { privateKey, publicKey } = generateKeyPairSync("ed25519");
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import type { JWK } from "jose";
|
|
2
|
+
import type { OidcHttp, OidcHttpRequest, OidcHttpResponse, OidcOptions } from "../oidc/options.js";
|
|
3
|
+
import type { OidcClock } from "../oidc/primitives.js";
|
|
4
|
+
/** The claims of a token the fake issuer mints. */
|
|
5
|
+
export interface FakeTokenClaims {
|
|
6
|
+
/** The subject (`user-1`). */
|
|
7
|
+
readonly sub?: string | undefined;
|
|
8
|
+
readonly email?: string | undefined;
|
|
9
|
+
readonly name?: string | undefined;
|
|
10
|
+
readonly username?: string | undefined;
|
|
11
|
+
/** The realm roles (`realm_access.roles`). */
|
|
12
|
+
readonly roles?: readonly string[] | undefined;
|
|
13
|
+
/** The client roles (`resource_access.<client>.roles`). */
|
|
14
|
+
readonly clientRoles?: Readonly<Record<string, readonly string[]>> | undefined;
|
|
15
|
+
/** The provider session (`sid`); one is generated when absent. */
|
|
16
|
+
readonly sessionId?: string | undefined;
|
|
17
|
+
/** Further claims, copied as they are. */
|
|
18
|
+
readonly extra?: Readonly<Record<string, unknown>> | undefined;
|
|
19
|
+
}
|
|
20
|
+
/** What a spec bends in one token to prove a refusal. */
|
|
21
|
+
export interface MintOverrides {
|
|
22
|
+
/** The `iss` claim (the issuer's own when absent). */
|
|
23
|
+
readonly issuer?: string | undefined;
|
|
24
|
+
/** The `aud` claim (the issuer's `audience`, else its client id). */
|
|
25
|
+
readonly audience?: string | readonly string[] | undefined;
|
|
26
|
+
/** Seconds the token lives from `issuedAt` (the issuer's access TTL). Negative mints an expired token. */
|
|
27
|
+
readonly expiresInSeconds?: number | undefined;
|
|
28
|
+
/** The `iat` instant (the clock's now). */
|
|
29
|
+
readonly issuedAt?: Date | undefined;
|
|
30
|
+
/** The `nbf` instant. */
|
|
31
|
+
readonly notBefore?: Date | undefined;
|
|
32
|
+
/** The `kid` header: a text, or `null` to omit it (the signing key's id when absent). */
|
|
33
|
+
readonly kid?: string | null | undefined;
|
|
34
|
+
/** `foreign` signs with a key the issuer does not publish. */
|
|
35
|
+
readonly signer?: "current" | "foreign" | undefined;
|
|
36
|
+
/** The `typ` claim (`Bearer` for an access token, `ID` for an ID token). */
|
|
37
|
+
readonly type?: string | undefined;
|
|
38
|
+
}
|
|
39
|
+
/** The options of a fake issuer. */
|
|
40
|
+
export interface FakeOidcIssuerOptions {
|
|
41
|
+
/** The realm segment of the issuer address (`test`). */
|
|
42
|
+
readonly realm?: string | undefined;
|
|
43
|
+
/** The origin of an in-memory issuer (`https://idp.test`); ignored with `listen`. */
|
|
44
|
+
readonly origin?: string | undefined;
|
|
45
|
+
/** The registered client id (`test-client`). */
|
|
46
|
+
readonly clientId?: string | undefined;
|
|
47
|
+
/** The client secret the token endpoint requires; none makes the client public. */
|
|
48
|
+
readonly clientSecret?: string | undefined;
|
|
49
|
+
/** The audience of access tokens (the client id). */
|
|
50
|
+
readonly audience?: string | undefined;
|
|
51
|
+
/** The signature algorithm (`RS256`). */
|
|
52
|
+
readonly algorithm?: "RS256" | "ES256" | undefined;
|
|
53
|
+
/** The clock token times are read from (the system clock). */
|
|
54
|
+
readonly clock?: OidcClock | undefined;
|
|
55
|
+
/** How long an access token lives, in seconds (300). */
|
|
56
|
+
readonly accessTtlSeconds?: number | undefined;
|
|
57
|
+
/** 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. */
|
|
58
|
+
readonly listen?: boolean | undefined;
|
|
59
|
+
/** How long a refresh token lives, in seconds (1800); answered as `refresh_expires_in`. */
|
|
60
|
+
readonly refreshTtlSeconds?: number | undefined;
|
|
61
|
+
/** The aliases of the identity providers the realm brokers; an authorization address with another `kc_idp_hint` is refused. Any alias is admitted when absent. */
|
|
62
|
+
readonly identityProviders?: readonly string[] | undefined;
|
|
63
|
+
/** Whether the discovery document advertises a `revocation_endpoint` (true); false makes a client fall back to the end-session endpoint. */
|
|
64
|
+
readonly revocation?: boolean | undefined;
|
|
65
|
+
/** The shortest password the realm's policy admits (8); a shorter one is refused as Keycloak refuses it. */
|
|
66
|
+
readonly minPasswordLength?: number | undefined;
|
|
67
|
+
}
|
|
68
|
+
/** An account of the fake realm, for the password grant and the admin API. */
|
|
69
|
+
export interface FakeUser {
|
|
70
|
+
/** The account id: the `sub` of its tokens (generated when absent). */
|
|
71
|
+
readonly id?: string | undefined;
|
|
72
|
+
readonly username: string;
|
|
73
|
+
readonly email?: string | undefined;
|
|
74
|
+
readonly name?: string | undefined;
|
|
75
|
+
/** The realm roles its tokens carry. */
|
|
76
|
+
readonly roles?: readonly string[] | undefined;
|
|
77
|
+
/** False makes the password grant refuse the account (true). */
|
|
78
|
+
readonly enabled?: boolean | undefined;
|
|
79
|
+
}
|
|
80
|
+
/** A seeded account as the issuer holds it: its id is set. */
|
|
81
|
+
export type FakeSeededUser = FakeUser & {
|
|
82
|
+
readonly id: string;
|
|
83
|
+
};
|
|
84
|
+
/** What a failure injection does to the next requests. */
|
|
85
|
+
export type FakeFailure = {
|
|
86
|
+
readonly status: number;
|
|
87
|
+
} | "network";
|
|
88
|
+
/** A recorded request, for a spec to count what the code under test asked. */
|
|
89
|
+
export interface FakeRequest {
|
|
90
|
+
readonly method: string;
|
|
91
|
+
readonly path: string;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* A fake OIDC provider that behaves like Keycloak for what the package uses: the discovery document, the JWKS, the token endpoint (authorization
|
|
95
|
+
* code with PKCE, refresh token with rotation) and the logout endpoint, with keys that rotate and tokens a spec can bend (expired, wrong
|
|
96
|
+
* audience, wrong issuer, unknown key, foreign signature). Its `http` is an in-memory `OidcHttp` (no socket, no port), so an app's spec registers
|
|
97
|
+
* the module against it and needs no Keycloak; `listen: true` serves the same issuer on a real socket for a browser.
|
|
98
|
+
*/
|
|
99
|
+
export declare class FakeOidcIssuer {
|
|
100
|
+
#private;
|
|
101
|
+
readonly clientId: string;
|
|
102
|
+
private constructor();
|
|
103
|
+
/** Creates an issuer with one signing key, and starts its socket when `listen` is set. */
|
|
104
|
+
static create(options?: FakeOidcIssuerOptions): Promise<FakeOidcIssuer>;
|
|
105
|
+
/** The exact issuer: the `iss` claim and the `issuer` of the discovery document. */
|
|
106
|
+
get issuer(): string;
|
|
107
|
+
/** The in-memory outbound port: answers like the issuer's endpoints, with the failures the spec injected. */
|
|
108
|
+
get http(): OidcHttp;
|
|
109
|
+
/**
|
|
110
|
+
* 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
|
|
111
|
+
* test-world fake does. Tokens minted afterwards carry the new `iss` and the discovery document names the new endpoints; the keys stay.
|
|
112
|
+
*/
|
|
113
|
+
rebase(origin: string): void;
|
|
114
|
+
/** 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. */
|
|
115
|
+
serve(request: OidcHttpRequest): Promise<OidcHttpResponse>;
|
|
116
|
+
/** The same endpoints as a `fetch`, for a front-end client under test (the browser client takes a `fetch`, not an `OidcHttp`). */
|
|
117
|
+
get fetch(): typeof fetch;
|
|
118
|
+
/** The requests the issuer has answered, oldest first. */
|
|
119
|
+
get requests(): readonly FakeRequest[];
|
|
120
|
+
/** The `kc_idp_hint` of every authorization address `authorize` has played, oldest first (an address without a hint adds nothing). */
|
|
121
|
+
get idpHints(): readonly string[];
|
|
122
|
+
/** Adds an account the password grant admits (by username or email) and the admin API lists. Answers it with its id. */
|
|
123
|
+
seedUser(user: FakeUser & {
|
|
124
|
+
readonly password: string;
|
|
125
|
+
}): FakeSeededUser;
|
|
126
|
+
/** The account with this id, username or email (case-insensitive), as the issuer holds it now; undefined when there is none. */
|
|
127
|
+
findUser(identifier: string): FakeSeededUser | undefined;
|
|
128
|
+
/** True when the account's current password is this one. */
|
|
129
|
+
passwordMatches(identifier: string, password: string): boolean;
|
|
130
|
+
/** How many refresh tokens the issuer holds that can still be used. */
|
|
131
|
+
get liveRefreshTokens(): number;
|
|
132
|
+
/** How many requests asked this path (`/protocol/openid-connect/certs`, `/.well-known/openid-configuration`). */
|
|
133
|
+
count(path: string): number;
|
|
134
|
+
/** The issuer and key set addresses an issuer preset takes as `endpoints` to stand this double in for Google or GitHub Actions. */
|
|
135
|
+
endpoints(): {
|
|
136
|
+
readonly issuer: string;
|
|
137
|
+
readonly jwksUri: string;
|
|
138
|
+
};
|
|
139
|
+
/** 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. */
|
|
140
|
+
options(overrides?: Partial<OidcOptions> & {
|
|
141
|
+
readonly callbackUri?: string;
|
|
142
|
+
readonly postLogoutUri?: string;
|
|
143
|
+
}): OidcOptions;
|
|
144
|
+
/** The discovery document. */
|
|
145
|
+
metadata(): Readonly<Record<string, unknown>>;
|
|
146
|
+
/** The published key set: every key of the ring. */
|
|
147
|
+
jwks(): {
|
|
148
|
+
readonly keys: readonly JWK[];
|
|
149
|
+
};
|
|
150
|
+
/** The `kid` of the key that signs now. */
|
|
151
|
+
get keyId(): string;
|
|
152
|
+
/** 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`. */
|
|
153
|
+
rotateKeys(options?: {
|
|
154
|
+
readonly keepOld?: boolean;
|
|
155
|
+
}): Promise<string>;
|
|
156
|
+
/** Mints an access token for the claims, signed by the current key unless an override bends it. */
|
|
157
|
+
mintAccessToken(claims?: FakeTokenClaims, overrides?: MintOverrides): Promise<string>;
|
|
158
|
+
/** Mints an ID token for the client, carrying the nonce of the transaction. */
|
|
159
|
+
mintIdToken(claims?: FakeTokenClaims, nonce?: string, overrides?: MintOverrides): Promise<string>;
|
|
160
|
+
/**
|
|
161
|
+
* 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
|
|
162
|
+
* answers the address the provider would redirect the browser to (the redirect address with `code`, `state` and `iss`).
|
|
163
|
+
*/
|
|
164
|
+
authorize(authorizationUrl: string, claims?: FakeTokenClaims): string;
|
|
165
|
+
/** Makes the next `count` requests fail: with an HTTP status, or as a lost connection. */
|
|
166
|
+
failNext(failure?: FakeFailure, count?: number): void;
|
|
167
|
+
/** Bends every ID token the token endpoint mints from now on (an empty object restores them). */
|
|
168
|
+
bendIdTokens(overrides?: MintOverrides): void;
|
|
169
|
+
/** Ends every refresh token the issuer holds, as an administrator signing the person out of the provider would: the next refresh answers `invalid_grant`. */
|
|
170
|
+
revokeRefreshTokens(): void;
|
|
171
|
+
/** While down, every request fails as a lost connection (an outage); `false` brings the issuer back. */
|
|
172
|
+
setDown(down: boolean): void;
|
|
173
|
+
/** Stops the socket, when there is one. */
|
|
174
|
+
close(): Promise<void>;
|
|
175
|
+
}
|