@starci/auth 0.0.0-stage → 0.2.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 +16 -0
- package/LICENSE +21 -0
- package/README.md +38 -2
- package/dist/client/auth-client.d.ts +68 -0
- package/dist/client/auth-client.js +192 -0
- package/dist/client/index.d.ts +8 -0
- package/dist/client/index.js +4 -0
- package/dist/client/outcome.d.ts +15 -0
- package/dist/client/outcome.js +22 -0
- package/dist/client/session.d.ts +57 -0
- package/dist/client/session.js +68 -0
- package/dist/client/shared.d.ts +20 -0
- package/dist/client/shared.js +42 -0
- package/dist/contracts/index.d.ts +111 -0
- package/dist/contracts/index.js +71 -0
- package/dist/react/index.d.ts +21 -0
- package/dist/react/index.js +42 -0
- package/dist/server/auth.module.d.ts +29 -0
- package/dist/server/auth.module.js +85 -0
- package/dist/server/auth.tokens.d.ts +24 -0
- package/dist/server/auth.tokens.js +24 -0
- package/dist/server/budget.d.ts +21 -0
- package/dist/server/budget.js +38 -0
- package/dist/server/cookie.d.ts +19 -0
- package/dist/server/cookie.js +54 -0
- package/dist/server/doors.d.ts +44 -0
- package/dist/server/doors.js +72 -0
- package/dist/server/errors.d.ts +24 -0
- package/dist/server/errors.js +18 -0
- package/dist/server/flows.d.ts +21 -0
- package/dist/server/flows.js +66 -0
- package/dist/server/google-state.d.ts +33 -0
- package/dist/server/google-state.js +73 -0
- package/dist/server/http.d.ts +27 -0
- package/dist/server/http.js +1 -0
- package/dist/server/index.d.ts +24 -0
- package/dist/server/index.js +15 -0
- package/dist/server/options.d.ts +81 -0
- package/dist/server/options.js +1 -0
- package/dist/server/passwords.d.ts +11 -0
- package/dist/server/passwords.js +22 -0
- package/dist/server/ports.d.ts +145 -0
- package/dist/server/ports.js +1 -0
- package/dist/server/primitives.d.ts +36 -0
- package/dist/server/primitives.js +67 -0
- package/dist/server/runtime.d.ts +5 -0
- package/dist/server/runtime.js +5 -0
- package/dist/server/sessions.d.ts +45 -0
- package/dist/server/sessions.js +153 -0
- package/dist/server/strategies.d.ts +29 -0
- package/dist/server/strategies.js +55 -0
- package/dist/server/tokens.d.ts +26 -0
- package/dist/server/tokens.js +130 -0
- package/dist/server/verifier.d.ts +20 -0
- package/dist/server/verifier.js +23 -0
- package/dist/testing/index.d.ts +52 -0
- package/dist/testing/index.js +158 -0
- package/migrations/identity-sessions.sql +58 -0
- package/package.json +126 -4
|
@@ -0,0 +1,111 @@
|
|
|
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 declare const AUTH_PREFIX = "auth";
|
|
7
|
+
/** The REST doors of sign-in, relative to the API origin. */
|
|
8
|
+
export declare const AUTH_DOORS: {
|
|
9
|
+
readonly login: "/auth/login";
|
|
10
|
+
readonly signup: "/auth/signup";
|
|
11
|
+
readonly resend: "/auth/resend";
|
|
12
|
+
readonly confirm: "/auth/confirm";
|
|
13
|
+
readonly refresh: "/auth/refresh";
|
|
14
|
+
readonly session: "/auth/session";
|
|
15
|
+
readonly logout: "/auth/logout";
|
|
16
|
+
readonly forgot: "/auth/forgot";
|
|
17
|
+
readonly reset: "/auth/reset";
|
|
18
|
+
readonly google: "/auth/google";
|
|
19
|
+
readonly googleCallback: "/auth/google/callback";
|
|
20
|
+
readonly jwks: "/auth/jwks";
|
|
21
|
+
};
|
|
22
|
+
/** The name of one door. */
|
|
23
|
+
export type AuthDoorName = keyof typeof AUTH_DOORS;
|
|
24
|
+
/** The `role` claim every access token carries; any other role is refused. */
|
|
25
|
+
export declare const AUTH_ROLE = "authenticated";
|
|
26
|
+
/** The audience every access token names. */
|
|
27
|
+
export declare const AUTH_AUDIENCE = "authenticated";
|
|
28
|
+
/** The claim names of an access token beyond the registered ones. */
|
|
29
|
+
export declare const AUTH_CLAIMS: {
|
|
30
|
+
readonly role: "role";
|
|
31
|
+
readonly sessionId: "session_id";
|
|
32
|
+
readonly email: "email";
|
|
33
|
+
};
|
|
34
|
+
/** The signature algorithm of every access token: pinned, never negotiated. */
|
|
35
|
+
export declare const AUTH_ALGORITHM = "EdDSA";
|
|
36
|
+
/** The codes the doors answer on the wire; the front end maps them to its sentences. */
|
|
37
|
+
export declare const AUTH_ERROR_CODES: {
|
|
38
|
+
readonly Unauthenticated: "IDENTITY_UNAUTHENTICATED";
|
|
39
|
+
readonly AccessTokenInvalid: "PASSPORT_ACCESS_TOKEN_INVALID";
|
|
40
|
+
readonly KeyInvalid: "PASSPORT_KEY_INVALID";
|
|
41
|
+
readonly MissingCredentials: "PASSPORT_MISSING_CREDENTIALS";
|
|
42
|
+
readonly GoogleStateInvalid: "PASSPORT_GOOGLE_STATE_INVALID";
|
|
43
|
+
readonly GoogleRejected: "PASSPORT_GOOGLE_REJECTED";
|
|
44
|
+
readonly GoogleUnavailable: "PASSPORT_GOOGLE_UNAVAILABLE";
|
|
45
|
+
readonly RefreshInvalid: "IDENTITY_REFRESH_INVALID";
|
|
46
|
+
readonly RefreshReused: "IDENTITY_REFRESH_REUSED";
|
|
47
|
+
readonly AccountDisabled: "IDENTITY_ACCOUNT_DISABLED";
|
|
48
|
+
readonly TooManyAttempts: "IDENTITY_TOO_MANY_ATTEMPTS";
|
|
49
|
+
readonly InvalidCredentials: "IDENTITY_INVALID_CREDENTIALS";
|
|
50
|
+
readonly EmailNotConfirmed: "IDENTITY_EMAIL_NOT_CONFIRMED";
|
|
51
|
+
readonly PasswordInvalid: "IDENTITY_PASSWORD_INVALID";
|
|
52
|
+
readonly ResetTokenInvalid: "IDENTITY_RESET_TOKEN_INVALID";
|
|
53
|
+
};
|
|
54
|
+
/** One wire code of sign-in. */
|
|
55
|
+
export type AuthErrorCode = (typeof AUTH_ERROR_CODES)[keyof typeof AUTH_ERROR_CODES];
|
|
56
|
+
/** How a code travels: the family of status the transport maps it to. */
|
|
57
|
+
export type AuthErrorKind = "unauthenticated" | "forbidden" | "invalid" | "rate-limited" | "internal" | "unavailable";
|
|
58
|
+
/** The kind of every code. */
|
|
59
|
+
export declare const AUTH_ERROR_KINDS: Readonly<Record<AuthErrorCode, AuthErrorKind>>;
|
|
60
|
+
/** The person a session belongs to. */
|
|
61
|
+
export interface SessionUser {
|
|
62
|
+
/** The user id. */
|
|
63
|
+
readonly id: string;
|
|
64
|
+
/** The account email. */
|
|
65
|
+
readonly email: string;
|
|
66
|
+
}
|
|
67
|
+
/** What a sign-in, refresh or session door answers in the body. */
|
|
68
|
+
export interface AccessGrant {
|
|
69
|
+
/** The access JWT, to be sent as a Bearer token. */
|
|
70
|
+
readonly accessToken: string;
|
|
71
|
+
/** How many seconds the access token lives. */
|
|
72
|
+
readonly expiresIn: number;
|
|
73
|
+
/** When the access token stops being accepted, ISO 8601. */
|
|
74
|
+
readonly expiresAt: string;
|
|
75
|
+
/** The signed-in person. */
|
|
76
|
+
readonly user: SessionUser;
|
|
77
|
+
}
|
|
78
|
+
/** The attempts a sign-in budget counts separately. */
|
|
79
|
+
export type AttemptKind = "login" | "reset" | "signup";
|
|
80
|
+
/** One public key of the JWKS document. */
|
|
81
|
+
export type PublicJwk = Readonly<Record<string, string>>;
|
|
82
|
+
/** The public key document served at the JWKS door. */
|
|
83
|
+
export interface JwksDocument {
|
|
84
|
+
/** The public keys access tokens verify against. */
|
|
85
|
+
readonly keys: readonly PublicJwk[];
|
|
86
|
+
}
|
|
87
|
+
/** Where the browser goes after the Google round trip. */
|
|
88
|
+
export interface GoogleRedirect {
|
|
89
|
+
/** The absolute address. */
|
|
90
|
+
readonly url: string;
|
|
91
|
+
/** The redirect status. */
|
|
92
|
+
readonly statusCode: number;
|
|
93
|
+
}
|
|
94
|
+
/** The kinds of failure a front-end call ends with, as the doors' statuses stand for them. */
|
|
95
|
+
export type AuthFailureKind = "refused" | "not-found" | "invalid" | "unavailable";
|
|
96
|
+
/** The outcome of a front-end auth call: a value, or a failure carrying its kind and the wire code. */
|
|
97
|
+
export type AuthOutcome<T> = {
|
|
98
|
+
readonly success: true;
|
|
99
|
+
readonly message: "ok";
|
|
100
|
+
readonly data: T;
|
|
101
|
+
} | {
|
|
102
|
+
readonly success: false;
|
|
103
|
+
readonly message: AuthFailureKind;
|
|
104
|
+
readonly error: string;
|
|
105
|
+
};
|
|
106
|
+
/** The facts both sides hold about a password. */
|
|
107
|
+
export declare const PASSWORD_BOUNDS: {
|
|
108
|
+
readonly min: 8;
|
|
109
|
+
readonly max: 256;
|
|
110
|
+
readonly bcryptMaxBytes: 72;
|
|
111
|
+
};
|
|
@@ -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
|
+
}
|