@starci/auth 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/LICENSE +21 -0
  3. package/README.md +38 -2
  4. package/dist/client/auth-client.d.ts +70 -0
  5. package/dist/client/auth-client.js +225 -0
  6. package/dist/client/index.d.ts +6 -0
  7. package/dist/client/index.js +3 -0
  8. package/dist/client/outcome.d.ts +15 -0
  9. package/dist/client/outcome.js +22 -0
  10. package/dist/client/shared.d.ts +20 -0
  11. package/dist/client/shared.js +42 -0
  12. package/dist/contracts/index.d.ts +111 -0
  13. package/dist/contracts/index.js +71 -0
  14. package/dist/react/index.d.ts +21 -0
  15. package/dist/react/index.js +42 -0
  16. package/dist/server/auth.module.d.ts +29 -0
  17. package/dist/server/auth.module.js +85 -0
  18. package/dist/server/auth.tokens.d.ts +24 -0
  19. package/dist/server/auth.tokens.js +24 -0
  20. package/dist/server/budget.d.ts +21 -0
  21. package/dist/server/budget.js +38 -0
  22. package/dist/server/cookie.d.ts +19 -0
  23. package/dist/server/cookie.js +54 -0
  24. package/dist/server/doors.d.ts +44 -0
  25. package/dist/server/doors.js +72 -0
  26. package/dist/server/errors.d.ts +24 -0
  27. package/dist/server/errors.js +18 -0
  28. package/dist/server/flows.d.ts +21 -0
  29. package/dist/server/flows.js +66 -0
  30. package/dist/server/google-state.d.ts +33 -0
  31. package/dist/server/google-state.js +73 -0
  32. package/dist/server/http.d.ts +27 -0
  33. package/dist/server/http.js +1 -0
  34. package/dist/server/index.d.ts +24 -0
  35. package/dist/server/index.js +15 -0
  36. package/dist/server/options.d.ts +81 -0
  37. package/dist/server/options.js +1 -0
  38. package/dist/server/passwords.d.ts +11 -0
  39. package/dist/server/passwords.js +22 -0
  40. package/dist/server/ports.d.ts +145 -0
  41. package/dist/server/ports.js +1 -0
  42. package/dist/server/primitives.d.ts +36 -0
  43. package/dist/server/primitives.js +67 -0
  44. package/dist/server/runtime.d.ts +5 -0
  45. package/dist/server/runtime.js +5 -0
  46. package/dist/server/sessions.d.ts +45 -0
  47. package/dist/server/sessions.js +153 -0
  48. package/dist/server/strategies.d.ts +29 -0
  49. package/dist/server/strategies.js +55 -0
  50. package/dist/server/tokens.d.ts +26 -0
  51. package/dist/server/tokens.js +130 -0
  52. package/dist/server/verifier.d.ts +20 -0
  53. package/dist/server/verifier.js +23 -0
  54. package/dist/testing/index.d.ts +52 -0
  55. package/dist/testing/index.js +158 -0
  56. package/migrations/identity-sessions.sql +58 -0
  57. package/package.json +126 -4
@@ -0,0 +1,45 @@
1
+ import type { JwksDocument } from "../contracts/index.js";
2
+ import type { AuthOptions } from "./options.js";
3
+ import type { AuthClock, AuthIds, AuthLogger, AuthSubject, DeviceMeta, SessionTokens } from "./ports.js";
4
+ import type { AuthTokens } from "./tokens.js";
5
+ /** A rotated token presented again this soon after its rotation is a racing tab, not a theft. */
6
+ export declare const DEFAULT_REUSE_GRACE_MS = 30000;
7
+ /** The log event of a replayed refresh token. */
8
+ export declare const REFRESH_REUSE_EVENT = "auth.refresh-reuse-detected";
9
+ /** What the sessions need from the module. */
10
+ export interface SessionsDeps {
11
+ readonly options: Pick<AuthOptions, "refreshTtlSeconds" | "reuseGraceMs" | "sessions" | "accounts">;
12
+ readonly tokens: AuthTokens;
13
+ readonly clock: AuthClock;
14
+ readonly ids: AuthIds;
15
+ readonly logger?: AuthLogger | undefined;
16
+ }
17
+ /**
18
+ * Device sessions: opens one at sign-in, rotates its refresh token on every refresh (a replayed token ends the session) and ends it
19
+ * at sign-out. A token rotated within the grace window is a racing request: it is answered with an access token only, the cookie
20
+ * is left as the winner wrote it.
21
+ */
22
+ export declare class AuthSessions {
23
+ #private;
24
+ constructor(deps: SessionsDeps);
25
+ /** Opens a device session for a person who just proved who they are, and answers its first tokens. */
26
+ start(subject: AuthSubject, device: DeviceMeta): Promise<SessionTokens>;
27
+ /**
28
+ * Exchanges a refresh token for a new pair; the presented token is spent. Throws when it is unknown, expired, or rotated long
29
+ * ago (reuse ends the session). A token a sibling request rotated within the grace window answers an access token marked
30
+ * `cookieKept`, so the caller leaves the cookie alone: the sibling already wrote the successor.
31
+ */
32
+ refresh(presented: string): Promise<SessionTokens>;
33
+ /**
34
+ * Answers a fresh access token for the session of a refresh token WITHOUT rotating it, so parallel gated requests of one browser
35
+ * can ask at once. A token rotated within the grace window still counts (a sibling request just rotated it); an older replay
36
+ * ends the session.
37
+ */
38
+ access(presented: string): Promise<SessionTokens>;
39
+ /** The public key document access tokens verify against. */
40
+ jwks(): Promise<JwksDocument>;
41
+ /** Signs the person out of every other device; the session of the caller stays. */
42
+ endOthers(userId: string, keepSessionId: string | undefined): Promise<void>;
43
+ /** Ends the session a refresh token belongs to (an empty or unknown token changes nothing). */
44
+ end(presented: string): Promise<void>;
45
+ }
@@ -0,0 +1,153 @@
1
+ import { AUTH_ERROR_CODES } from "../contracts/index.js";
2
+ import { AuthError } from "./errors.js";
3
+ import { digestOf } from "./primitives.js";
4
+ /** A rotated token presented again this soon after its rotation is a racing tab, not a theft. */
5
+ export const DEFAULT_REUSE_GRACE_MS = 30_000;
6
+ /** The log event of a replayed refresh token. */
7
+ export const REFRESH_REUSE_EVENT = "auth.refresh-reuse-detected";
8
+ const MILLISECONDS = 1000;
9
+ /**
10
+ * Device sessions: opens one at sign-in, rotates its refresh token on every refresh (a replayed token ends the session) and ends it
11
+ * at sign-out. A token rotated within the grace window is a racing request: it is answered with an access token only, the cookie
12
+ * is left as the winner wrote it.
13
+ */
14
+ export class AuthSessions {
15
+ #deps;
16
+ constructor(deps) {
17
+ this.#deps = deps;
18
+ }
19
+ get #store() {
20
+ return this.#deps.options.sessions;
21
+ }
22
+ get #graceMs() {
23
+ return this.#deps.options.reuseGraceMs ?? DEFAULT_REUSE_GRACE_MS;
24
+ }
25
+ /** Opens a device session for a person who just proved who they are, and answers its first tokens. */
26
+ async start(subject, device) {
27
+ const now = this.#deps.clock.now();
28
+ const sessionId = this.#deps.ids.next();
29
+ const refreshToken = this.#mintToken();
30
+ await this.#store.transaction(async (tx) => {
31
+ await tx.openSession({ id: sessionId, userId: subject.userId, now, userAgent: device.userAgent, ip: device.ip });
32
+ await tx.insertToken({
33
+ id: this.#deps.ids.next(),
34
+ sessionId,
35
+ userId: subject.userId,
36
+ tokenHash: digestOf(refreshToken),
37
+ expiresAt: this.#refreshExpiry(now),
38
+ });
39
+ });
40
+ return this.#tokensFor(subject, sessionId, refreshToken, now);
41
+ }
42
+ /**
43
+ * Exchanges a refresh token for a new pair; the presented token is spent. Throws when it is unknown, expired, or rotated long
44
+ * ago (reuse ends the session). A token a sibling request rotated within the grace window answers an access token marked
45
+ * `cookieKept`, so the caller leaves the cookie alone: the sibling already wrote the successor.
46
+ */
47
+ async refresh(presented) {
48
+ if (presented === "")
49
+ throw new AuthError(AUTH_ERROR_CODES.RefreshInvalid);
50
+ const now = this.#deps.clock.now();
51
+ const nextToken = this.#mintToken();
52
+ const rotation = await this.#store.transaction(async (tx) => {
53
+ const row = await tx.findTokenForUpdate(digestOf(presented));
54
+ if (row === null || row.expiresAt <= now)
55
+ return { kind: "invalid" };
56
+ if (row.usedAt !== null) {
57
+ return now.getTime() - row.usedAt.getTime() <= this.#graceMs
58
+ ? { kind: "raced", row }
59
+ : { kind: "reused", sessionId: row.sessionId };
60
+ }
61
+ await tx.markTokenUsed(row.id, now);
62
+ await tx.insertToken({
63
+ id: this.#deps.ids.next(),
64
+ sessionId: row.sessionId,
65
+ userId: row.userId,
66
+ tokenHash: digestOf(nextToken),
67
+ expiresAt: this.#refreshExpiry(now),
68
+ });
69
+ await tx.deleteExpiredTokens(row.userId, now);
70
+ return { kind: "rotated", row, next: nextToken };
71
+ });
72
+ if (rotation.kind === "reused") {
73
+ await this.#store.closeSession(rotation.sessionId);
74
+ this.#deps.logger?.warn(REFRESH_REUSE_EVENT, { sessionId: rotation.sessionId });
75
+ throw new AuthError(AUTH_ERROR_CODES.RefreshReused);
76
+ }
77
+ if (rotation.kind === "invalid")
78
+ throw new AuthError(AUTH_ERROR_CODES.RefreshInvalid);
79
+ if (rotation.kind === "raced")
80
+ return this.#accessOf(rotation.row, presented, now);
81
+ const user = await this.#deps.options.accounts.find(rotation.row.userId);
82
+ if (user === undefined || user.disabled || user.email === null) {
83
+ await this.#store.closeSession(rotation.row.sessionId);
84
+ throw new AuthError(AUTH_ERROR_CODES.AccountDisabled);
85
+ }
86
+ await this.#store.touchSession(rotation.row.sessionId, now);
87
+ return this.#tokensFor({ userId: user.id, email: user.email }, rotation.row.sessionId, rotation.next, now);
88
+ }
89
+ /**
90
+ * Answers a fresh access token for the session of a refresh token WITHOUT rotating it, so parallel gated requests of one browser
91
+ * can ask at once. A token rotated within the grace window still counts (a sibling request just rotated it); an older replay
92
+ * ends the session.
93
+ */
94
+ async access(presented) {
95
+ if (presented === "")
96
+ throw new AuthError(AUTH_ERROR_CODES.RefreshInvalid);
97
+ const now = this.#deps.clock.now();
98
+ const row = await this.#store.findToken(digestOf(presented));
99
+ if (row === null || row.expiresAt <= now)
100
+ throw new AuthError(AUTH_ERROR_CODES.RefreshInvalid);
101
+ if (row.usedAt !== null && now.getTime() - row.usedAt.getTime() > this.#graceMs) {
102
+ await this.#store.closeSession(row.sessionId);
103
+ this.#deps.logger?.warn(REFRESH_REUSE_EVENT, { sessionId: row.sessionId });
104
+ throw new AuthError(AUTH_ERROR_CODES.RefreshReused);
105
+ }
106
+ return this.#accessOf(row, presented, now);
107
+ }
108
+ /** The public key document access tokens verify against. */
109
+ jwks() {
110
+ return this.#deps.tokens.jwks();
111
+ }
112
+ /** Signs the person out of every other device; the session of the caller stays. */
113
+ async endOthers(userId, keepSessionId) {
114
+ if (keepSessionId === undefined)
115
+ throw new AuthError(AUTH_ERROR_CODES.Unauthenticated);
116
+ await this.#store.closeOtherSessions(userId, keepSessionId);
117
+ }
118
+ /** Ends the session a refresh token belongs to (an empty or unknown token changes nothing). */
119
+ async end(presented) {
120
+ if (presented === "")
121
+ return;
122
+ const row = await this.#store.findToken(digestOf(presented));
123
+ if (row !== null)
124
+ await this.#store.closeSession(row.sessionId);
125
+ }
126
+ /** An access token for the session of a token row, without rotating; an account that is gone or disabled ends the session. */
127
+ async #accessOf(row, presented, now) {
128
+ const user = await this.#deps.options.accounts.find(row.userId);
129
+ if (user === undefined || user.disabled || user.email === null) {
130
+ await this.#store.closeSession(row.sessionId);
131
+ throw new AuthError(AUTH_ERROR_CODES.AccountDisabled);
132
+ }
133
+ const tokens = await this.#tokensFor({ userId: user.id, email: user.email }, row.sessionId, presented, now);
134
+ return { ...tokens, cookieKept: true };
135
+ }
136
+ async #tokensFor(subject, sessionId, refreshToken, now) {
137
+ const access = await this.#deps.tokens.sign({ sub: subject.userId, email: subject.email, sessionId }, now);
138
+ return {
139
+ accessToken: access.token,
140
+ accessExpiresAt: access.expiresAt.toISOString(),
141
+ accessExpiresIn: Math.round((access.expiresAt.getTime() - now.getTime()) / MILLISECONDS),
142
+ cookieToken: refreshToken,
143
+ refreshMaxAgeSeconds: this.#deps.options.refreshTtlSeconds,
144
+ user: { id: subject.userId, email: subject.email },
145
+ };
146
+ }
147
+ #refreshExpiry(now) {
148
+ return new Date(now.getTime() + this.#deps.options.refreshTtlSeconds * MILLISECONDS);
149
+ }
150
+ #mintToken() {
151
+ return `${this.#deps.ids.next()}${this.#deps.ids.next()}`.replaceAll("-", "");
152
+ }
153
+ }
@@ -0,0 +1,29 @@
1
+ import { Strategy as GoogleStrategy } from "passport-google-oauth20";
2
+ import type { Profile } from "passport-google-oauth20";
3
+ import { Strategy as LocalStrategy } from "passport-local";
4
+ import type { GoogleStateClient } from "./google-state.js";
5
+ import type { GoogleOptions } from "./options.js";
6
+ import type { AuthAuthenticator, AuthSubject, GoogleProfile } from "./ports.js";
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;
9
+ declare const PassportLocalStrategy_base: new (...args: [] | [options: import("passport-local").IStrategyOptionsWithRequest] | [options: import("passport-local").IStrategyOptions]) => LocalStrategy & {
10
+ validate(...args: any[]): unknown;
11
+ };
12
+ /** The email and password strategy; the app decides whether the pair is right. */
13
+ export declare class PassportLocalStrategy extends PassportLocalStrategy_base {
14
+ #private;
15
+ constructor(authenticator: AuthAuthenticator);
16
+ /** Called by passport with the posted pair; answers the person or throws the app's refusal. */
17
+ validate(email: string, password: string): Promise<AuthSubject>;
18
+ }
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 {};
@@ -0,0 +1,55 @@
1
+ import { PassportStrategy } from "@nestjs/passport";
2
+ import { Strategy as GoogleStrategy } from "passport-google-oauth20";
3
+ import { Strategy as LocalStrategy } from "passport-local";
4
+ import { AUTH_ERROR_CODES } from "../contracts/index.js";
5
+ import { AuthError } from "./errors.js";
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
+ };
23
+ /** The email and password strategy; the app decides whether the pair is right. */
24
+ export class PassportLocalStrategy extends PassportStrategy(LocalStrategy, "local") {
25
+ #authenticator;
26
+ constructor(authenticator) {
27
+ super({ usernameField: "email", passwordField: "password", session: false });
28
+ this.#authenticator = authenticator;
29
+ }
30
+ /** Called by passport with the posted pair; answers the person or throws the app's refusal. */
31
+ validate(email, password) {
32
+ return this.#authenticator.verifyPassword(email, password);
33
+ }
34
+ }
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
+ }
@@ -0,0 +1,26 @@
1
+ import type { JwksDocument } from "../contracts/index.js";
2
+ import type { Outcome } from "./errors.js";
3
+ import type { AuthOptions } from "./options.js";
4
+ import type { AccessClaims, IssuedAccessToken } from "./ports.js";
5
+ import type { SignedState } from "./primitives.js";
6
+ /** The options this signer needs. */
7
+ export type TokenOptions = Pick<AuthOptions, "issuer" | "audience" | "keys" | "accessTtlSeconds">;
8
+ /**
9
+ * The one signer and verifier of our access tokens (Ed25519 JWTs, the algorithm pinned) and of the short-lived Google state.
10
+ * The key set holds one signing key and any number of retired keys: all of them verify and are published as the JWKS, so a key
11
+ * rotates without signing anybody out (add the new key first, make it the signer, drop the old one when its tokens have expired).
12
+ */
13
+ export declare class AuthTokens {
14
+ #private;
15
+ constructor(options: TokenOptions);
16
+ /** Signs an access token for one device session. */
17
+ sign(claims: AccessClaims, now: Date): Promise<IssuedAccessToken>;
18
+ /** The claims of a token that passed signature (against any key of the set), issuer, audience and expiry, or a refusal. */
19
+ verify(token: string, now: Date): Promise<Outcome<AccessClaims>>;
20
+ /** The public key document: every key of the set. */
21
+ jwks(): Promise<JwksDocument>;
22
+ /** Signs the state of a Google round trip (an HMAC under a key derived from the signing key). */
23
+ signState(state: SignedState): string;
24
+ /** The state when the value is ours, unexpired and untampered; otherwise null. */
25
+ verifyState(value: string, now: Date): SignedState | null;
26
+ }
@@ -0,0 +1,130 @@
1
+ import { createHash, createHmac, createPrivateKey, createPublicKey } from "node:crypto";
2
+ import { SignJWT, calculateJwkThumbprint, createLocalJWKSet, exportJWK, importPKCS8, importSPKI, jwtVerify } from "jose";
3
+ import { AUTH_ALGORITHM, AUTH_AUDIENCE, AUTH_CLAIMS, AUTH_ERROR_CODES, AUTH_ROLE } from "../contracts/index.js";
4
+ import { AuthError, ok, refused } from "./errors.js";
5
+ import { revealed, signStateToken, verifyStateToken } from "./primitives.js";
6
+ const STATE_LABEL = "passport.google-state";
7
+ const pemOf = (value) => value.replaceAll(String.raw `\n`, "\n");
8
+ /**
9
+ * The one signer and verifier of our access tokens (Ed25519 JWTs, the algorithm pinned) and of the short-lived Google state.
10
+ * The key set holds one signing key and any number of retired keys: all of them verify and are published as the JWKS, so a key
11
+ * rotates without signing anybody out (add the new key first, make it the signer, drop the old one when its tokens have expired).
12
+ */
13
+ export class AuthTokens {
14
+ #options;
15
+ #ring;
16
+ constructor(options) {
17
+ this.#options = options;
18
+ }
19
+ /** Signs an access token for one device session. */
20
+ async sign(claims, now) {
21
+ const { signer } = await this.#keys();
22
+ const issuedAt = Math.floor(now.getTime() / 1000);
23
+ const expiresAt = issuedAt + this.#options.accessTtlSeconds;
24
+ const token = await new SignJWT({
25
+ [AUTH_CLAIMS.role]: AUTH_ROLE,
26
+ [AUTH_CLAIMS.sessionId]: claims.sessionId,
27
+ ...(claims.email === undefined ? {} : { [AUTH_CLAIMS.email]: claims.email }),
28
+ })
29
+ .setProtectedHeader({ alg: AUTH_ALGORITHM, kid: signer.keyId, typ: "JWT" })
30
+ .setSubject(claims.sub)
31
+ .setIssuer(this.#options.issuer)
32
+ .setAudience(this.#options.audience ?? AUTH_AUDIENCE)
33
+ .setIssuedAt(issuedAt)
34
+ .setExpirationTime(expiresAt)
35
+ .sign(signer.privateKey);
36
+ return { token, expiresAt: new Date(expiresAt * 1000) };
37
+ }
38
+ /** The claims of a token that passed signature (against any key of the set), issuer, audience and expiry, or a refusal. */
39
+ async verify(token, now) {
40
+ const { verifyingSet } = await this.#keys();
41
+ try {
42
+ const { payload } = await jwtVerify(token, verifyingSet, {
43
+ algorithms: [AUTH_ALGORITHM],
44
+ issuer: this.#options.issuer,
45
+ audience: this.#options.audience ?? AUTH_AUDIENCE,
46
+ currentDate: now,
47
+ });
48
+ const sessionId = payload[AUTH_CLAIMS.sessionId];
49
+ if (payload[AUTH_CLAIMS.role] !== AUTH_ROLE || typeof payload.sub !== "string" || typeof sessionId !== "string") {
50
+ return refused(AUTH_ERROR_CODES.AccessTokenInvalid);
51
+ }
52
+ const email = payload[AUTH_CLAIMS.email];
53
+ return ok({ sub: payload.sub, sessionId, ...(typeof email === "string" ? { email } : {}) });
54
+ }
55
+ catch (cause) {
56
+ return refused(AUTH_ERROR_CODES.AccessTokenInvalid, { cause: String(cause) });
57
+ }
58
+ }
59
+ /** The public key document: every key of the set. */
60
+ async jwks() {
61
+ return (await this.#keys()).document;
62
+ }
63
+ /** Signs the state of a Google round trip (an HMAC under a key derived from the signing key). */
64
+ signState(state) {
65
+ return signStateToken(this.#stateKey(), state);
66
+ }
67
+ /** The state when the value is ours, unexpired and untampered; otherwise null. */
68
+ verifyState(value, now) {
69
+ return verifyStateToken(this.#stateKey(), value, now.getTime());
70
+ }
71
+ #stateKey() {
72
+ const signer = this.#options.keys.find((key) => key.privateKey !== undefined);
73
+ if (signer?.privateKey === undefined)
74
+ throw new AuthError(AUTH_ERROR_CODES.KeyInvalid, { context: { reason: "no signing key" } });
75
+ const pem = pemOf(revealed(signer.privateKey));
76
+ const material = createHash("sha256").update(pem, "utf8").digest();
77
+ return createHmac("sha256", material).update(STATE_LABEL).digest();
78
+ }
79
+ #keys() {
80
+ this.#ring ??= this.#load();
81
+ return this.#ring;
82
+ }
83
+ async #load() {
84
+ const loaded = await Promise.all(this.#options.keys.map((key) => loadKey(key)));
85
+ const signers = loaded.filter((key) => key.privateKey !== undefined);
86
+ const [signer] = signers;
87
+ if (signers.length !== 1 || signer?.privateKey === undefined) {
88
+ throw new AuthError(AUTH_ERROR_CODES.KeyInvalid, { context: { reason: "the key set needs exactly one signing key", signers: signers.length } });
89
+ }
90
+ const ids = new Set(loaded.map((key) => key.keyId));
91
+ if (ids.size !== loaded.length)
92
+ throw new AuthError(AUTH_ERROR_CODES.KeyInvalid, { context: { reason: "two keys share a kid" } });
93
+ const keys = loaded.map((key) => ({ ...key.jwk }));
94
+ return {
95
+ signer: { ...signer, privateKey: signer.privateKey },
96
+ verifyingSet: createLocalJWKSet({ keys }),
97
+ document: { keys: loaded.map((key) => key.jwk) },
98
+ };
99
+ }
100
+ }
101
+ const publicPemOf = (key) => {
102
+ if (key.publicKey !== undefined)
103
+ return pemOf(key.publicKey);
104
+ if (key.privateKey === undefined)
105
+ throw new Error("a key needs a public key or a private key");
106
+ return createPublicKey(createPrivateKey(pemOf(revealed(key.privateKey)))).export({ type: "spki", format: "pem" }).toString();
107
+ };
108
+ const publicJwkOf = async (publicKey, keyId) => {
109
+ const key = await importSPKI(publicKey, AUTH_ALGORITHM, { extractable: true });
110
+ const exported = await exportJWK(key);
111
+ const kid = keyId ?? (await calculateJwkThumbprint(exported));
112
+ const jwk = {};
113
+ for (const [name, value] of Object.entries({ ...exported, kid, alg: AUTH_ALGORITHM, use: "sig" })) {
114
+ if (typeof value === "string")
115
+ jwk[name] = value;
116
+ }
117
+ return { jwk, keyId: kid };
118
+ };
119
+ const loadKey = async (key) => {
120
+ try {
121
+ const [privateKey, published] = await Promise.all([
122
+ key.privateKey === undefined ? Promise.resolve(undefined) : importPKCS8(pemOf(revealed(key.privateKey)), AUTH_ALGORITHM),
123
+ publicJwkOf(publicPemOf(key), key.keyId),
124
+ ]);
125
+ return { keyId: published.keyId, jwk: published.jwk, privateKey };
126
+ }
127
+ catch (cause) {
128
+ throw new AuthError(AUTH_ERROR_CODES.KeyInvalid, { cause });
129
+ }
130
+ };
@@ -0,0 +1,20 @@
1
+ import type { Outcome } from "./errors.js";
2
+ import type { AuthClock, AuthPrincipal } from "./ports.js";
3
+ import type { AuthTokens } from "./tokens.js";
4
+ /**
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-oidc` behind this same interface, so guards,
7
+ * `@Public` and `@Credential` do not change between editions.
8
+ */
9
+ export interface PrincipalVerifier {
10
+ /** Verifies one token at one instant. */
11
+ verify(token: string, now?: Date): Promise<Outcome<AuthPrincipal>>;
12
+ }
13
+ /** The verifier of the app that signs people in: our own access token, checked against our key set, issuer, audience and expiry. */
14
+ export declare class AuthPrincipalVerifier implements PrincipalVerifier {
15
+ #private;
16
+ constructor(tokens: AuthTokens, clock?: AuthClock);
17
+ verify(token: string, now?: Date): Promise<Outcome<AuthPrincipal>>;
18
+ }
19
+ /** The verifier of an app that serves no user session: every access token is refused, so the default-deny guard admits only its public and credential doors. */
20
+ export declare const refuseSessions: PrincipalVerifier;
@@ -0,0 +1,23 @@
1
+ import { AUTH_ERROR_CODES, AUTH_ROLE } from "../contracts/index.js";
2
+ import { ok, refused } from "./errors.js";
3
+ import { systemClock } from "./runtime.js";
4
+ /** The verifier of the app that signs people in: our own access token, checked against our key set, issuer, audience and expiry. */
5
+ export class AuthPrincipalVerifier {
6
+ #tokens;
7
+ #clock;
8
+ constructor(tokens, clock = systemClock) {
9
+ this.#tokens = tokens;
10
+ this.#clock = clock;
11
+ }
12
+ async verify(token, now = this.#clock.now()) {
13
+ const verified = await this.#tokens.verify(token, now);
14
+ if (verified.kind === "refused")
15
+ return refused(AUTH_ERROR_CODES.AccessTokenInvalid);
16
+ const { sub, email, sessionId } = verified.value;
17
+ return ok({ id: sub, role: AUTH_ROLE, sessionId, ...(email === undefined ? {} : { email }) });
18
+ }
19
+ }
20
+ /** The verifier of an app that serves no user session: every access token is refused, so the default-deny guard admits only its public and credential doors. */
21
+ export const refuseSessions = {
22
+ verify: () => Promise.resolve(refused(AUTH_ERROR_CODES.AccessTokenInvalid)),
23
+ };
@@ -0,0 +1,52 @@
1
+ import type { AuthOptions, SigningKeyOptions } from "../server/options.js";
2
+ import type { AccountDirectory, AccountStanding, AuthClock, AuthIds, NewSession, RateCounter, RefreshTokenRecord, SessionStore, SessionTransaction } from "../server/ports.js";
3
+ /** A generated Ed25519 key pair as the PEM texts the options take. */
4
+ export interface TestKeyPair {
5
+ readonly privateKey: string;
6
+ readonly publicKey: string;
7
+ }
8
+ /** Generates an Ed25519 key pair (PKCS#8 private PEM, SPKI public PEM). */
9
+ export declare const generateTestKeyPair: () => TestKeyPair;
10
+ /** A signing key for the options; `signer: false` makes it a verify-only (retired) key. */
11
+ export declare const testSigningKey: (pair?: TestKeyPair, keyId?: string, signer?: boolean) => SigningKeyOptions;
12
+ /** A clock the test moves by hand. */
13
+ export declare class FakeClock implements AuthClock {
14
+ #private;
15
+ constructor(start?: Date | number);
16
+ now(): Date;
17
+ /** Moves time forward. */
18
+ advance(milliseconds: number): void;
19
+ }
20
+ /** Ids that count up, so a spec reads them. */
21
+ export declare const sequentialIds: (prefix?: string) => AuthIds;
22
+ /** An in-memory `SessionStore` with the same rotation semantics the SQL tables give (a transaction applies all or none of its writes). */
23
+ export declare class MemorySessionStore implements SessionStore {
24
+ #private;
25
+ readonly sessions: Map<string, NewSession>;
26
+ readonly tokens: Map<string, RefreshTokenRecord>;
27
+ transaction<T>(work: (tx: SessionTransaction) => Promise<T>): Promise<T>;
28
+ findToken(tokenHash: string): Promise<RefreshTokenRecord | null>;
29
+ touchSession(): Promise<void>;
30
+ closeSession(sessionId: string): Promise<void>;
31
+ closeOtherSessions(userId: string, keepSessionId: string): Promise<void>;
32
+ }
33
+ /** An in-memory account directory. */
34
+ export declare class MemoryAccounts implements AccountDirectory {
35
+ readonly accounts: Map<string, AccountStanding>;
36
+ /** Adds or replaces an account. */
37
+ set(account: AccountStanding): this;
38
+ find(userId: string): Promise<AccountStanding | undefined>;
39
+ }
40
+ /** An in-memory rate counter with fixed windows that the clock moves. */
41
+ export declare class MemoryRateCounter implements RateCounter {
42
+ #private;
43
+ constructor(clock: AuthClock);
44
+ consume(key: string, windowMs: number, limit: number): Promise<boolean>;
45
+ clear(key: string): Promise<void>;
46
+ }
47
+ /** Options for a spec: one fresh signing key, in-memory stores and a fake clock. Override any field. */
48
+ export declare const testAuthOptions: (overrides?: Partial<Omit<AuthOptions, "clock" | "sessions" | "accounts">>) => AuthOptions & {
49
+ readonly clock: FakeClock;
50
+ readonly sessions: MemorySessionStore;
51
+ readonly accounts: MemoryAccounts;
52
+ };