@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,71 @@
1
+ /**
2
+ * The contract both sides of the sign-in share: the door paths, the wire codes, the claim names and the shapes of the answers.
3
+ * The server controller and the front-end client read these constants, so a door can never drift between them.
4
+ */
5
+ /** The path prefix of every auth door. */
6
+ export const AUTH_PREFIX = "auth";
7
+ /** The REST doors of sign-in, relative to the API origin. */
8
+ export const AUTH_DOORS = {
9
+ login: "/auth/login",
10
+ signup: "/auth/signup",
11
+ resend: "/auth/resend",
12
+ confirm: "/auth/confirm",
13
+ refresh: "/auth/refresh",
14
+ session: "/auth/session",
15
+ logout: "/auth/logout",
16
+ forgot: "/auth/forgot",
17
+ reset: "/auth/reset",
18
+ google: "/auth/google",
19
+ googleCallback: "/auth/google/callback",
20
+ jwks: "/auth/jwks",
21
+ };
22
+ /** The `role` claim every access token carries; any other role is refused. */
23
+ export const AUTH_ROLE = "authenticated";
24
+ /** The audience every access token names. */
25
+ export const AUTH_AUDIENCE = "authenticated";
26
+ /** The claim names of an access token beyond the registered ones. */
27
+ export const AUTH_CLAIMS = {
28
+ role: "role",
29
+ sessionId: "session_id",
30
+ email: "email",
31
+ };
32
+ /** The signature algorithm of every access token: pinned, never negotiated. */
33
+ export const AUTH_ALGORITHM = "EdDSA";
34
+ /** The codes the doors answer on the wire; the front end maps them to its sentences. */
35
+ export const AUTH_ERROR_CODES = {
36
+ Unauthenticated: "IDENTITY_UNAUTHENTICATED",
37
+ AccessTokenInvalid: "PASSPORT_ACCESS_TOKEN_INVALID",
38
+ KeyInvalid: "PASSPORT_KEY_INVALID",
39
+ MissingCredentials: "PASSPORT_MISSING_CREDENTIALS",
40
+ GoogleStateInvalid: "PASSPORT_GOOGLE_STATE_INVALID",
41
+ GoogleRejected: "PASSPORT_GOOGLE_REJECTED",
42
+ GoogleUnavailable: "PASSPORT_GOOGLE_UNAVAILABLE",
43
+ RefreshInvalid: "IDENTITY_REFRESH_INVALID",
44
+ RefreshReused: "IDENTITY_REFRESH_REUSED",
45
+ AccountDisabled: "IDENTITY_ACCOUNT_DISABLED",
46
+ TooManyAttempts: "IDENTITY_TOO_MANY_ATTEMPTS",
47
+ InvalidCredentials: "IDENTITY_INVALID_CREDENTIALS",
48
+ EmailNotConfirmed: "IDENTITY_EMAIL_NOT_CONFIRMED",
49
+ PasswordInvalid: "IDENTITY_PASSWORD_INVALID",
50
+ ResetTokenInvalid: "IDENTITY_RESET_TOKEN_INVALID",
51
+ };
52
+ /** The kind of every code. */
53
+ export const AUTH_ERROR_KINDS = {
54
+ [AUTH_ERROR_CODES.Unauthenticated]: "unauthenticated",
55
+ [AUTH_ERROR_CODES.AccessTokenInvalid]: "unauthenticated",
56
+ [AUTH_ERROR_CODES.KeyInvalid]: "internal",
57
+ [AUTH_ERROR_CODES.MissingCredentials]: "invalid",
58
+ [AUTH_ERROR_CODES.GoogleStateInvalid]: "unauthenticated",
59
+ [AUTH_ERROR_CODES.GoogleRejected]: "unauthenticated",
60
+ [AUTH_ERROR_CODES.GoogleUnavailable]: "unavailable",
61
+ [AUTH_ERROR_CODES.RefreshInvalid]: "unauthenticated",
62
+ [AUTH_ERROR_CODES.RefreshReused]: "unauthenticated",
63
+ [AUTH_ERROR_CODES.AccountDisabled]: "forbidden",
64
+ [AUTH_ERROR_CODES.TooManyAttempts]: "rate-limited",
65
+ [AUTH_ERROR_CODES.InvalidCredentials]: "unauthenticated",
66
+ [AUTH_ERROR_CODES.EmailNotConfirmed]: "forbidden",
67
+ [AUTH_ERROR_CODES.PasswordInvalid]: "invalid",
68
+ [AUTH_ERROR_CODES.ResetTokenInvalid]: "invalid",
69
+ };
70
+ /** The facts both sides hold about a password. */
71
+ export const PASSWORD_BOUNDS = { min: 8, max: 256, bcryptMaxBytes: 72 };
@@ -0,0 +1,21 @@
1
+ import type { ReactElement, ReactNode } from "react";
2
+ import type { AuthClient, AuthSnapshot } from "../client/index.js";
3
+ /** The props of the provider. */
4
+ export interface AuthProviderProps {
5
+ /** The one client of the front end (`createAuthClient`). */
6
+ readonly client: AuthClient;
7
+ readonly children?: ReactNode;
8
+ }
9
+ /** Makes the client reachable to the hooks below. It draws nothing: it is a context, never a visual. */
10
+ export declare const AuthProvider: ({ client, children }: AuthProviderProps) => ReactElement;
11
+ /** The client of the nearest provider. */
12
+ export declare const useAuthClient: () => AuthClient;
13
+ /** The session snapshot: loading until the first answer (the first subscriber starts it), then signed in or out. */
14
+ export declare const useAuthSession: () => AuthSnapshot;
15
+ /** Calls `onSignedIn` once the session is signed in (a public sign-in page sends a visitor who already has a session on). */
16
+ export declare const useWhenSignedIn: (onSignedIn: () => void) => void;
17
+ /** A resend cooldown: `start()` begins a countdown from `seconds`; `left` is 0 when ready. */
18
+ export declare const useCooldown: (seconds: number, startActive?: boolean) => {
19
+ readonly left: number;
20
+ readonly start: () => void;
21
+ };
@@ -0,0 +1,42 @@
1
+ import { createContext, createElement, useCallback, useContext, useEffect, useState, useSyncExternalStore } from "react";
2
+ const AuthClientContext = createContext(null);
3
+ /** Makes the client reachable to the hooks below. It draws nothing: it is a context, never a visual. */
4
+ export const AuthProvider = ({ client, children }) => createElement(AuthClientContext.Provider, { value: client }, children);
5
+ /** The client of the nearest provider. */
6
+ export const useAuthClient = () => {
7
+ const client = useContext(AuthClientContext);
8
+ if (client === null)
9
+ throw new Error("useAuthClient needs an AuthProvider above it");
10
+ return client;
11
+ };
12
+ /** The session snapshot: loading until the first answer (the first subscriber starts it), then signed in or out. */
13
+ export const useAuthSession = () => {
14
+ const client = useAuthClient();
15
+ return useSyncExternalStore(client.watch, client.getSnapshot, client.getServerSnapshot);
16
+ };
17
+ /** Calls `onSignedIn` once the session is signed in (a public sign-in page sends a visitor who already has a session on). */
18
+ export const useWhenSignedIn = (onSignedIn) => {
19
+ const { status } = useAuthSession();
20
+ useEffect(() => {
21
+ if (status === "signed-in")
22
+ onSignedIn();
23
+ }, [status, onSignedIn]);
24
+ };
25
+ /** A resend cooldown: `start()` begins a countdown from `seconds`; `left` is 0 when ready. */
26
+ export const useCooldown = (seconds, startActive = false) => {
27
+ const [left, setLeft] = useState(startActive ? seconds : 0);
28
+ useEffect(() => {
29
+ if (left <= 0)
30
+ return undefined;
31
+ const id = setTimeout(() => {
32
+ setLeft((current) => current - 1);
33
+ }, 1000);
34
+ return () => {
35
+ clearTimeout(id);
36
+ };
37
+ }, [left]);
38
+ const start = useCallback(() => {
39
+ setLeft(seconds);
40
+ }, [seconds]);
41
+ return { left, start };
42
+ };
@@ -0,0 +1,29 @@
1
+ import type { DynamicModule, InjectionToken } from "@nestjs/common";
2
+ import type { AuthOptions } from "./options.js";
3
+ /** The extras every registration sets beside the options. */
4
+ export interface AuthModuleExtras {
5
+ /** Whether the module is global. */
6
+ readonly isGlobal: boolean;
7
+ /**
8
+ * The provider token of the app's `AuthAuthenticator` (its password and Google decisions). The app's authenticator uses
9
+ * `AuthPasswords` and `SignInBudget` of this module, so it is resolved by token here, never passed as a built instance.
10
+ */
11
+ readonly authenticator: InjectionToken | undefined;
12
+ }
13
+ export declare const ConfigurableModuleClass: import("@nestjs/common").ConfigurableModuleCls<AuthOptions, "register", "createAuthOptions", AuthModuleExtras>, MODULE_OPTIONS_TOKEN: string | symbol, OPTIONS_TYPE: AuthOptions & Partial<AuthModuleExtras>, ASYNC_OPTIONS_TYPE: import("@nestjs/common").ConfigurableModuleAsyncOptions<AuthOptions, "createAuthOptions"> & Partial<AuthModuleExtras>;
14
+ /** What `AuthModule.register` takes: the options, plus `isGlobal` and the `authenticator` token. */
15
+ export type AuthRegisterOptions = typeof OPTIONS_TYPE;
16
+ /** What `AuthModule.registerAsync` takes: `imports`, `inject` and one of `useFactory`, `useClass`, `useExisting`, plus `isGlobal` and the `authenticator` token. */
17
+ export type AuthRegisterAsyncOptions = typeof ASYNC_OPTIONS_TYPE;
18
+ /**
19
+ * The sign-in of a lite app as one Nest capability: the token signer and verifier, the passwords, the sign-in budget, the
20
+ * rotating sessions, the refresh cookie, the passport flows (local and Google with state and PKCE) and the doors that run them.
21
+ * Registered once at the api root; the app implements the stores (`SessionStore`, `AccountDirectory`, `RateCounter`) and the
22
+ * `AuthAuthenticator` over its own tables.
23
+ */
24
+ export declare class AuthModule extends ConfigurableModuleClass {
25
+ /** Registers the module from options known now. */
26
+ static register(options: AuthRegisterOptions): DynamicModule;
27
+ /** Registers the module from options a factory, class or existing provider supplies. */
28
+ static registerAsync(options: AuthRegisterAsyncOptions): DynamicModule;
29
+ }
@@ -0,0 +1,85 @@
1
+ import { ConfigurableModuleBuilder, Module } from "@nestjs/common";
2
+ import { AuthDoors } from "./doors.js";
3
+ import { RefreshCookie } from "./cookie.js";
4
+ import { AuthFlows } from "./flows.js";
5
+ import { GoogleStateClient } from "./google-state.js";
6
+ import { AuthPasswords } from "./passwords.js";
7
+ import { randomIds, systemClock } from "./runtime.js";
8
+ import { AuthSessions } from "./sessions.js";
9
+ import { PassportGoogleStrategy, PassportLocalStrategy } from "./strategies.js";
10
+ import { AuthTokens } from "./tokens.js";
11
+ import { AuthPrincipalVerifier } from "./verifier.js";
12
+ import { SignInBudget } from "./budget.js";
13
+ import { AUTH_AUTHENTICATOR, AUTH_DOORS_SERVICE, AUTH_FLOWS, AUTH_GOOGLE_STATE, AUTH_GOOGLE_STRATEGY, AUTH_LOCAL_STRATEGY, AUTH_PASSWORDS, AUTH_PRINCIPAL_VERIFIER, AUTH_REFRESH_COOKIE, AUTH_SESSIONS, AUTH_SIGN_IN_BUDGET, AUTH_TOKENS, } from "./auth.tokens.js";
14
+ export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN, OPTIONS_TYPE, ASYNC_OPTIONS_TYPE } = new ConfigurableModuleBuilder({ moduleName: "Auth" })
15
+ .setClassMethodName("register")
16
+ .setFactoryMethodName("createAuthOptions")
17
+ .setExtras({ isGlobal: false, authenticator: undefined }, (definition, extras) => ({ ...definition, global: extras.isGlobal }))
18
+ .build();
19
+ /** The token a registration without an authenticator resolves to: no provider answers it, so the boot fails loudly. */
20
+ const AUTH_AUTHENTICATOR_MISSING = Symbol.for("@starci/auth/authenticator-missing");
21
+ const providers = (authenticator) => [
22
+ { provide: AUTH_AUTHENTICATOR, useExisting: authenticator ?? AUTH_AUTHENTICATOR_MISSING },
23
+ { provide: AUTH_TOKENS, inject: [MODULE_OPTIONS_TOKEN], useFactory: (options) => new AuthTokens(options) },
24
+ { provide: AUTH_PASSWORDS, inject: [MODULE_OPTIONS_TOKEN], useFactory: (options) => new AuthPasswords(options) },
25
+ {
26
+ provide: AUTH_SIGN_IN_BUDGET,
27
+ inject: [MODULE_OPTIONS_TOKEN],
28
+ useFactory: (options) => new SignInBudget(options.budget, options.rate),
29
+ },
30
+ {
31
+ provide: AUTH_SESSIONS,
32
+ inject: [MODULE_OPTIONS_TOKEN, AUTH_TOKENS],
33
+ useFactory: (options, tokens) => new AuthSessions({ options, tokens, clock: options.clock ?? systemClock, ids: options.ids ?? randomIds, logger: options.logger }),
34
+ },
35
+ { provide: AUTH_REFRESH_COOKIE, inject: [MODULE_OPTIONS_TOKEN], useFactory: (options) => new RefreshCookie(options.cookie) },
36
+ {
37
+ provide: AUTH_GOOGLE_STATE,
38
+ inject: [MODULE_OPTIONS_TOKEN, AUTH_TOKENS],
39
+ useFactory: (options, tokens) => new GoogleStateClient({ tokens, clock: options.clock ?? systemClock, ids: options.ids ?? randomIds, options }),
40
+ },
41
+ { provide: AUTH_FLOWS, inject: [MODULE_OPTIONS_TOKEN], useFactory: (options) => new AuthFlows(options) },
42
+ {
43
+ provide: AUTH_LOCAL_STRATEGY,
44
+ inject: [AUTH_AUTHENTICATOR],
45
+ useFactory: (authenticator) => new PassportLocalStrategy(authenticator),
46
+ },
47
+ {
48
+ provide: AUTH_GOOGLE_STRATEGY,
49
+ inject: [MODULE_OPTIONS_TOKEN, AUTH_AUTHENTICATOR, AUTH_GOOGLE_STATE],
50
+ useFactory: (options, authenticator, state) => options.google === null ? null : new PassportGoogleStrategy(options.google, authenticator, state),
51
+ },
52
+ {
53
+ provide: AUTH_DOORS_SERVICE,
54
+ inject: [MODULE_OPTIONS_TOKEN, AUTH_FLOWS, AUTH_SESSIONS, AUTH_REFRESH_COOKIE],
55
+ useFactory: (options, flows, sessions, cookie) => new AuthDoors({ flows, sessions, cookie, returnOrigin: options.returnOrigin, logger: options.logger }),
56
+ },
57
+ {
58
+ provide: AUTH_PRINCIPAL_VERIFIER,
59
+ inject: [MODULE_OPTIONS_TOKEN, AUTH_TOKENS],
60
+ useFactory: (options, tokens) => new AuthPrincipalVerifier(tokens, options.clock ?? systemClock),
61
+ },
62
+ ];
63
+ const EXPORTED = [AUTH_TOKENS, AUTH_PASSWORDS, AUTH_SIGN_IN_BUDGET, AUTH_SESSIONS, AUTH_REFRESH_COOKIE, AUTH_FLOWS, AUTH_DOORS_SERVICE, AUTH_PRINCIPAL_VERIFIER];
64
+ const withProviders = (base, authenticator) => ({
65
+ ...base,
66
+ providers: [...(base.providers ?? []), ...providers(authenticator)],
67
+ exports: [...(base.exports ?? []), ...EXPORTED],
68
+ });
69
+ /**
70
+ * The sign-in of a lite app as one Nest capability: the token signer and verifier, the passwords, the sign-in budget, the
71
+ * rotating sessions, the refresh cookie, the passport flows (local and Google with state and PKCE) and the doors that run them.
72
+ * Registered once at the api root; the app implements the stores (`SessionStore`, `AccountDirectory`, `RateCounter`) and the
73
+ * `AuthAuthenticator` over its own tables.
74
+ */
75
+ export class AuthModule extends ConfigurableModuleClass {
76
+ /** Registers the module from options known now. */
77
+ static register(options) {
78
+ return withProviders(super.register(options), options.authenticator);
79
+ }
80
+ /** Registers the module from options a factory, class or existing provider supplies. */
81
+ static registerAsync(options) {
82
+ return withProviders(super.registerAsync(options), options.authenticator);
83
+ }
84
+ }
85
+ Module({})(AuthModule);
@@ -0,0 +1,24 @@
1
+ /** Token of the app's authenticator (resolved from the token the registration names). */
2
+ export declare const AUTH_AUTHENTICATOR: unique symbol;
3
+ /** Token of `AuthTokens`, the access-token signer and verifier. */
4
+ export declare const AUTH_TOKENS: unique symbol;
5
+ /** Token of `AuthPasswords`. */
6
+ export declare const AUTH_PASSWORDS: unique symbol;
7
+ /** Token of `SignInBudget`. */
8
+ export declare const AUTH_SIGN_IN_BUDGET: unique symbol;
9
+ /** Token of `AuthSessions`, the rotating device sessions. */
10
+ export declare const AUTH_SESSIONS: unique symbol;
11
+ /** Token of `RefreshCookie`. */
12
+ export declare const AUTH_REFRESH_COOKIE: unique symbol;
13
+ /** Token of the Google round-trip state store. */
14
+ export declare const AUTH_GOOGLE_STATE: unique symbol;
15
+ /** Token of `AuthFlows`, the passport strategy runs. */
16
+ export declare const AUTH_FLOWS: unique symbol;
17
+ /** Token of the local passport strategy (registered with passport when constructed). */
18
+ export declare const AUTH_LOCAL_STRATEGY: unique symbol;
19
+ /** Token of the Google passport strategy, or null when the deployment has no Google client. */
20
+ export declare const AUTH_GOOGLE_STRATEGY: unique symbol;
21
+ /** Token of `AuthDoors`, what a controller calls. */
22
+ export declare const AUTH_DOORS_SERVICE: unique symbol;
23
+ /** Token of the `PrincipalVerifier` the identity domain injects. */
24
+ export declare const AUTH_PRINCIPAL_VERIFIER: unique symbol;
@@ -0,0 +1,24 @@
1
+ /** Token of the app's authenticator (resolved from the token the registration names). */
2
+ export const AUTH_AUTHENTICATOR = Symbol.for("@starci/auth/authenticator");
3
+ /** Token of `AuthTokens`, the access-token signer and verifier. */
4
+ export const AUTH_TOKENS = Symbol.for("@starci/auth/tokens");
5
+ /** Token of `AuthPasswords`. */
6
+ export const AUTH_PASSWORDS = Symbol.for("@starci/auth/passwords");
7
+ /** Token of `SignInBudget`. */
8
+ export const AUTH_SIGN_IN_BUDGET = Symbol.for("@starci/auth/sign-in-budget");
9
+ /** Token of `AuthSessions`, the rotating device sessions. */
10
+ export const AUTH_SESSIONS = Symbol.for("@starci/auth/sessions");
11
+ /** Token of `RefreshCookie`. */
12
+ export const AUTH_REFRESH_COOKIE = Symbol.for("@starci/auth/refresh-cookie");
13
+ /** Token of the Google round-trip state store. */
14
+ export const AUTH_GOOGLE_STATE = Symbol.for("@starci/auth/google-state");
15
+ /** Token of `AuthFlows`, the passport strategy runs. */
16
+ export const AUTH_FLOWS = Symbol.for("@starci/auth/flows");
17
+ /** Token of the local passport strategy (registered with passport when constructed). */
18
+ export const AUTH_LOCAL_STRATEGY = Symbol.for("@starci/auth/local-strategy");
19
+ /** Token of the Google passport strategy, or null when the deployment has no Google client. */
20
+ export const AUTH_GOOGLE_STRATEGY = Symbol.for("@starci/auth/google-strategy");
21
+ /** Token of `AuthDoors`, what a controller calls. */
22
+ export const AUTH_DOORS_SERVICE = Symbol.for("@starci/auth/doors");
23
+ /** Token of the `PrincipalVerifier` the identity domain injects. */
24
+ export const AUTH_PRINCIPAL_VERIFIER = Symbol.for("@starci/auth/principal-verifier");
@@ -0,0 +1,21 @@
1
+ import type { AttemptKind } from "../contracts/index.js";
2
+ import type { BudgetOptions } from "./options.js";
3
+ import type { RateCounter } from "./ports.js";
4
+ /**
5
+ * Counts sign-in, reset and signup attempts per address in a window every replica shares, and refuses an address over its
6
+ * limit. A sign-in budget is a budget of failures: the password check that succeeds clears the count of its address, so good
7
+ * sign-ins never lock an account out.
8
+ */
9
+ export declare class SignInBudget {
10
+ #private;
11
+ constructor(options: BudgetOptions, rate: RateCounter);
12
+ /** Counts one attempt of this kind for an address (hashed in the counter key); throws when the address is over its limit. */
13
+ admit(kind: AttemptKind, address: string): Promise<void>;
14
+ /** Forgets the attempts of this kind counted for an address: its password was proved right, so earlier failures no longer weigh on it. */
15
+ clear(kind: AttemptKind, address: string): Promise<void>;
16
+ /** Admits one attempt, runs the check, and clears the address when the check passes; only failures stay counted. */
17
+ counted<T>(kind: AttemptKind, address: string, check: () => Promise<{
18
+ readonly passed: boolean;
19
+ readonly value: T;
20
+ }>): Promise<T>;
21
+ }
@@ -0,0 +1,38 @@
1
+ import { AUTH_ERROR_CODES } from "../contracts/index.js";
2
+ import { AuthError } from "./errors.js";
3
+ import { digestOf } from "./primitives.js";
4
+ /**
5
+ * Counts sign-in, reset and signup attempts per address in a window every replica shares, and refuses an address over its
6
+ * limit. A sign-in budget is a budget of failures: the password check that succeeds clears the count of its address, so good
7
+ * sign-ins never lock an account out.
8
+ */
9
+ export class SignInBudget {
10
+ #options;
11
+ #rate;
12
+ constructor(options, rate) {
13
+ this.#options = options;
14
+ this.#rate = rate;
15
+ }
16
+ /** Counts one attempt of this kind for an address (hashed in the counter key); throws when the address is over its limit. */
17
+ async admit(kind, address) {
18
+ const limit = kind === "login" ? this.#options.loginAttempts : this.#options.resetAttempts;
19
+ const allowed = await this.#rate.consume(this.#keyOf(kind, address), this.#options.windowMs, limit);
20
+ if (!allowed)
21
+ throw new AuthError(AUTH_ERROR_CODES.TooManyAttempts);
22
+ }
23
+ /** Forgets the attempts of this kind counted for an address: its password was proved right, so earlier failures no longer weigh on it. */
24
+ async clear(kind, address) {
25
+ await this.#rate.clear(this.#keyOf(kind, address));
26
+ }
27
+ /** Admits one attempt, runs the check, and clears the address when the check passes; only failures stay counted. */
28
+ async counted(kind, address, check) {
29
+ await this.admit(kind, address);
30
+ const result = await check();
31
+ if (result.passed)
32
+ await this.clear(kind, address);
33
+ return result.value;
34
+ }
35
+ #keyOf(kind, address) {
36
+ return `${kind}:${digestOf(address)}`;
37
+ }
38
+ }
@@ -0,0 +1,19 @@
1
+ import type { HttpRequest, HttpResponse } from "./http.js";
2
+ import type { RefreshCookieOptions } from "./options.js";
3
+ import type { DeviceMeta, SessionTokens } from "./ports.js";
4
+ /**
5
+ * The refresh cookie: httpOnly and Secure on every write and clear, SameSite lax (or strict), on the configured domain so the web
6
+ * app and the API both see it. The token only ever leaves the back end through this cookie, never in a body or a URL.
7
+ */
8
+ export declare class RefreshCookie {
9
+ #private;
10
+ constructor(options: RefreshCookieOptions);
11
+ /** The refresh token the browser sent (empty text when there is none). */
12
+ read(request: HttpRequest): string;
13
+ /** The device a request comes from: its user agent (bounded) and caller address. */
14
+ deviceOf(request: HttpRequest): DeviceMeta;
15
+ /** Sets the cookie to the refresh token of a session, clearing the copies older sessions kept under their paths. */
16
+ issue(response: HttpResponse, tokens: SessionTokens): void;
17
+ /** Clears the cookie on its path and on the paths older sessions used. */
18
+ clear(response: HttpResponse): void;
19
+ }
@@ -0,0 +1,54 @@
1
+ import { cookieValue } from "./primitives.js";
2
+ const MAX_AGENT_LENGTH = 512;
3
+ const MILLISECONDS = 1000;
4
+ /**
5
+ * The refresh cookie: httpOnly and Secure on every write and clear, SameSite lax (or strict), on the configured domain so the web
6
+ * app and the API both see it. The token only ever leaves the back end through this cookie, never in a body or a URL.
7
+ */
8
+ export class RefreshCookie {
9
+ #options;
10
+ constructor(options) {
11
+ this.#options = options;
12
+ }
13
+ /** The refresh token the browser sent (empty text when there is none). */
14
+ read(request) {
15
+ const header = request.headers.cookie;
16
+ return cookieValue(Array.isArray(header) ? header.join("; ") : (header ?? ""), this.#options.name);
17
+ }
18
+ /** The device a request comes from: its user agent (bounded) and caller address. */
19
+ deviceOf(request) {
20
+ const agent = request.headers["user-agent"];
21
+ const text = Array.isArray(agent) ? agent[0] : agent;
22
+ return { userAgent: text?.slice(0, MAX_AGENT_LENGTH) ?? null, ip: request.ip ?? null };
23
+ }
24
+ /** Sets the cookie to the refresh token of a session, clearing the copies older sessions kept under their paths. */
25
+ issue(response, tokens) {
26
+ for (const path of this.#legacyPaths())
27
+ response.clearCookie(this.#options.name, this.#flags(path));
28
+ response.cookie(this.#options.name, tokens.cookieToken, {
29
+ ...this.#flags(this.#path()),
30
+ maxAge: tokens.refreshMaxAgeSeconds * MILLISECONDS,
31
+ });
32
+ }
33
+ /** Clears the cookie on its path and on the paths older sessions used. */
34
+ clear(response) {
35
+ for (const path of [this.#path(), ...this.#legacyPaths()])
36
+ response.clearCookie(this.#options.name, this.#flags(path));
37
+ }
38
+ #path() {
39
+ return this.#options.path ?? "/";
40
+ }
41
+ #legacyPaths() {
42
+ return (this.#options.legacyPaths ?? ["/auth"]).filter((path) => path !== this.#path());
43
+ }
44
+ #flags(path) {
45
+ const domain = this.#options.domain;
46
+ return {
47
+ httpOnly: true,
48
+ secure: true,
49
+ sameSite: this.#options.sameSite ?? "lax",
50
+ path,
51
+ ...(domain === undefined ? {} : { domain }),
52
+ };
53
+ }
54
+ }
@@ -0,0 +1,44 @@
1
+ import type { AccessGrant, GoogleRedirect } from "../contracts/index.js";
2
+ import type { RefreshCookie } from "./cookie.js";
3
+ import type { AuthFlows } from "./flows.js";
4
+ import type { HttpExchange } from "./http.js";
5
+ import type { AuthLogger, SessionTokens } from "./ports.js";
6
+ import type { AuthSessions } from "./sessions.js";
7
+ /** The status of the Google callback redirect. */
8
+ export declare const SEE_OTHER = 303;
9
+ /** The log event of a failed Google sign-in. */
10
+ export declare const GOOGLE_SIGN_IN_FAILED_EVENT = "auth.google-sign-in-failed";
11
+ /** The body a sign-in, refresh or session door answers. */
12
+ export declare const accessOf: (tokens: SessionTokens) => AccessGrant;
13
+ /** What the doors need. */
14
+ export interface DoorsDeps {
15
+ readonly flows: AuthFlows;
16
+ readonly sessions: AuthSessions;
17
+ readonly cookie: RefreshCookie;
18
+ readonly returnOrigin: string;
19
+ readonly logger?: AuthLogger | undefined;
20
+ }
21
+ /**
22
+ * The cookie doors of sign-in over the flows, the sessions and the cookie: what a controller calls. The refresh token only goes
23
+ * into the cookie; a refresh that cannot be used clears it, one a sibling request just rotated leaves it as the sibling wrote it.
24
+ */
25
+ export declare class AuthDoors {
26
+ #private;
27
+ constructor(deps: DoorsDeps);
28
+ /** Email and password through the local strategy; the refresh token goes into the cookie. */
29
+ login(exchange: HttpExchange): Promise<AccessGrant>;
30
+ /** Rotates the cookie token and answers a new access token; a cookie that cannot be used is cleared and the refusal rethrown. */
31
+ refresh(exchange: HttpExchange): Promise<AccessGrant>;
32
+ /** Answers a fresh access token for the cookie session without rotating the cookie. */
33
+ session(exchange: HttpExchange): Promise<AccessGrant>;
34
+ /** Ends the session of the cookie and clears it. */
35
+ logout(exchange: HttpExchange): Promise<void>;
36
+ /** Sends the browser to the Google consent screen. */
37
+ beginGoogle(exchange: HttpExchange): Promise<void>;
38
+ /**
39
+ * Finishes the Google round trip: state, PKCE and profile are verified, the account found, linked or created, its session opened
40
+ * and its cookie set. A failure never throws: the browser is sent back to the sign-in page, because a redirect-only door has
41
+ * nowhere to show an error body.
42
+ */
43
+ completeGoogle(exchange: HttpExchange): Promise<GoogleRedirect>;
44
+ }
@@ -0,0 +1,72 @@
1
+ /** The status of the Google callback redirect. */
2
+ export const SEE_OTHER = 303;
3
+ /** The log event of a failed Google sign-in. */
4
+ export const GOOGLE_SIGN_IN_FAILED_EVENT = "auth.google-sign-in-failed";
5
+ /** The body a sign-in, refresh or session door answers. */
6
+ export const accessOf = (tokens) => ({
7
+ accessToken: tokens.accessToken,
8
+ expiresIn: tokens.accessExpiresIn,
9
+ expiresAt: tokens.accessExpiresAt,
10
+ user: tokens.user,
11
+ });
12
+ /**
13
+ * The cookie doors of sign-in over the flows, the sessions and the cookie: what a controller calls. The refresh token only goes
14
+ * into the cookie; a refresh that cannot be used clears it, one a sibling request just rotated leaves it as the sibling wrote it.
15
+ */
16
+ export class AuthDoors {
17
+ #deps;
18
+ constructor(deps) {
19
+ this.#deps = deps;
20
+ }
21
+ /** Email and password through the local strategy; the refresh token goes into the cookie. */
22
+ async login(exchange) {
23
+ const subject = await this.#deps.flows.authenticateLocal(exchange);
24
+ const tokens = await this.#deps.sessions.start(subject, this.#deps.cookie.deviceOf(exchange.req));
25
+ this.#deps.cookie.issue(exchange.res, tokens);
26
+ return accessOf(tokens);
27
+ }
28
+ /** Rotates the cookie token and answers a new access token; a cookie that cannot be used is cleared and the refusal rethrown. */
29
+ async refresh(exchange) {
30
+ try {
31
+ const tokens = await this.#deps.sessions.refresh(this.#deps.cookie.read(exchange.req));
32
+ if (tokens.cookieKept !== true)
33
+ this.#deps.cookie.issue(exchange.res, tokens);
34
+ return accessOf(tokens);
35
+ }
36
+ catch (cause) {
37
+ this.#deps.cookie.clear(exchange.res);
38
+ throw cause;
39
+ }
40
+ }
41
+ /** Answers a fresh access token for the cookie session without rotating the cookie. */
42
+ async session(exchange) {
43
+ return accessOf(await this.#deps.sessions.access(this.#deps.cookie.read(exchange.req)));
44
+ }
45
+ /** Ends the session of the cookie and clears it. */
46
+ async logout(exchange) {
47
+ await this.#deps.sessions.end(this.#deps.cookie.read(exchange.req));
48
+ this.#deps.cookie.clear(exchange.res);
49
+ }
50
+ /** Sends the browser to the Google consent screen. */
51
+ beginGoogle(exchange) {
52
+ return this.#deps.flows.beginGoogle(exchange);
53
+ }
54
+ /**
55
+ * Finishes the Google round trip: state, PKCE and profile are verified, the account found, linked or created, its session opened
56
+ * and its cookie set. A failure never throws: the browser is sent back to the sign-in page, because a redirect-only door has
57
+ * nowhere to show an error body.
58
+ */
59
+ async completeGoogle(exchange) {
60
+ try {
61
+ const completion = await this.#deps.flows.completeGoogle(exchange);
62
+ const tokens = await this.#deps.sessions.start(completion.subject, this.#deps.cookie.deviceOf(exchange.req));
63
+ this.#deps.cookie.issue(exchange.res, tokens);
64
+ return { url: `${this.#deps.returnOrigin}${completion.next}`, statusCode: SEE_OTHER };
65
+ }
66
+ catch (cause) {
67
+ this.#deps.cookie.clear(exchange.res);
68
+ this.#deps.logger?.warn(GOOGLE_SIGN_IN_FAILED_EVENT, { cause: String(cause) });
69
+ return { url: `${this.#deps.returnOrigin}/sign-in?auth=error`, statusCode: SEE_OTHER };
70
+ }
71
+ }
72
+ }
@@ -0,0 +1,24 @@
1
+ import type { AuthErrorCode, AuthErrorKind } from "../contracts/index.js";
2
+ /** The one error class of sign-in: a wire code, the kind it travels as and optional context for the log. */
3
+ export declare class AuthError extends Error {
4
+ readonly code: AuthErrorCode;
5
+ readonly kind: AuthErrorKind;
6
+ readonly context: Readonly<Record<string, unknown>>;
7
+ constructor(code: AuthErrorCode, options?: {
8
+ readonly cause?: unknown;
9
+ readonly context?: Readonly<Record<string, unknown>>;
10
+ });
11
+ }
12
+ /** The result of a check that can refuse with a code. */
13
+ export type Outcome<T, E extends string = AuthErrorCode> = {
14
+ readonly kind: "ok";
15
+ readonly value: T;
16
+ } | {
17
+ readonly kind: "refused";
18
+ readonly code: E;
19
+ readonly detail?: Readonly<Record<string, unknown>>;
20
+ };
21
+ /** An accepted outcome. */
22
+ export declare const ok: <T>(value: T) => Outcome<T, never>;
23
+ /** A refused outcome. */
24
+ export declare const refused: <E extends string>(code: E, detail?: Readonly<Record<string, unknown>>) => Outcome<never, E>;
@@ -0,0 +1,18 @@
1
+ import { AUTH_ERROR_KINDS } from "../contracts/index.js";
2
+ /** The one error class of sign-in: a wire code, the kind it travels as and optional context for the log. */
3
+ export class AuthError extends Error {
4
+ code;
5
+ kind;
6
+ context;
7
+ constructor(code, options = {}) {
8
+ super(code, options.cause === undefined ? undefined : { cause: options.cause });
9
+ this.name = "AuthError";
10
+ this.code = code;
11
+ this.kind = AUTH_ERROR_KINDS[code];
12
+ this.context = options.context ?? {};
13
+ }
14
+ }
15
+ /** An accepted outcome. */
16
+ export const ok = (value) => ({ kind: "ok", value });
17
+ /** A refused outcome. */
18
+ export const refused = (code, detail) => detail === undefined ? { kind: "refused", code } : { kind: "refused", code, detail };
@@ -0,0 +1,21 @@
1
+ import type { HttpExchange } from "./http.js";
2
+ import type { AuthOptions } from "./options.js";
3
+ import type { AuthSubject } from "./ports.js";
4
+ /** The result of the Google callback leg. */
5
+ export interface GoogleCompletion {
6
+ /** The signed-in person. */
7
+ readonly subject: AuthSubject;
8
+ /** The same-site path the browser lands on. */
9
+ readonly next: string;
10
+ }
11
+ /** Runs the passport strategies for one request without sessions; the only caller of `passport.authenticate`. */
12
+ export declare class AuthFlows {
13
+ #private;
14
+ constructor(options: Pick<AuthOptions, "google">);
15
+ /** Runs the local strategy over the posted body; answers the person, or throws the app's refusal. */
16
+ authenticateLocal(exchange: HttpExchange): Promise<AuthSubject>;
17
+ /** Sends the browser to the Google consent screen (the response is the redirect); the optional `next` query value is kept in the state cookie. */
18
+ beginGoogle(exchange: HttpExchange): Promise<void>;
19
+ /** Finishes the Google round trip: the state cookie, the PKCE verifier and the profile must all check out. */
20
+ completeGoogle(exchange: HttpExchange): Promise<GoogleCompletion>;
21
+ }