@luckystack/login 0.10.2 → 0.12.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.
@@ -0,0 +1,96 @@
1
+ import { IncomingMessage, ServerResponse } from 'node:http';
2
+ import type { BaseSessionLayout as SessionLayout } from './sessionLayout.js';
3
+ import { type UserRecord } from './userAdapter.js';
4
+ interface LoginOrRegisterParams {
5
+ email?: string;
6
+ password?: string;
7
+ name?: string;
8
+ confirmPassword?: string;
9
+ }
10
+ export type TwoFactorMethod = 'totp' | 'email-code' | 'recovery-code';
11
+ export interface CredentialsLoginSuccess {
12
+ status: true;
13
+ reason: string;
14
+ newToken: string;
15
+ session: SessionLayout;
16
+ requiresTwoFactor?: undefined;
17
+ }
18
+ export interface CredentialsLoginChallenge {
19
+ status: true;
20
+ reason: string;
21
+ requiresTwoFactor: true;
22
+ challengeToken: string;
23
+ twoFactorMethods: TwoFactorMethod[];
24
+ newToken?: undefined;
25
+ session?: undefined;
26
+ }
27
+ export interface CredentialsLoginFailure {
28
+ status: false;
29
+ reason: string;
30
+ newToken?: undefined;
31
+ session?: undefined;
32
+ requiresTwoFactor?: undefined;
33
+ }
34
+ export type CredentialsLoginResult = CredentialsLoginSuccess | CredentialsLoginChallenge | CredentialsLoginFailure;
35
+ export declare const OAUTH_STATE_COOKIE_NAME = "ls-oauth-state";
36
+ export interface CreateOAuthStateResult {
37
+ /** Opaque `state` param put on the authorize URL + echoed back on callback. */
38
+ state: string;
39
+ /**
40
+ * Random nonce the server must set as the value of the {@link OAUTH_STATE_COOKIE_NAME}
41
+ * cookie (HttpOnly, SameSite=Lax, short TTL) at the authorize redirect. The
42
+ * callback requires this back to accept the flow (browser-binding, F1).
43
+ */
44
+ stateCookie: string;
45
+ /**
46
+ * PKCE `code_challenge` (S256) when the provider opted into PKCE — the server
47
+ * appends `code_challenge=<value>&code_challenge_method=S256` to the authorize
48
+ * URL. `undefined` for non-PKCE providers (default).
49
+ */
50
+ codeChallenge?: string;
51
+ }
52
+ export declare const createOAuthState: (providerName: string, options?: {
53
+ usePkce?: boolean;
54
+ returnUrl?: string;
55
+ }) => Promise<CreateOAuthStateResult | null>;
56
+ declare const registerWithCredentials: ({ email, password, name, confirmPassword, }: {
57
+ email: string;
58
+ password: string;
59
+ name: string;
60
+ confirmPassword: string;
61
+ }, options?: {
62
+ supersedeToken?: string;
63
+ }) => Promise<CredentialsLoginResult>;
64
+ export type TwoFactorGate = (user: UserRecord, context: {
65
+ requesterIp?: string;
66
+ }) => Promise<CredentialsLoginChallenge | null>;
67
+ export declare const registerTwoFactorGate: (gate: TwoFactorGate) => void;
68
+ export declare const finalizeLogin: (user: UserRecord, options: {
69
+ provider: string;
70
+ email?: string;
71
+ supersedeToken?: string;
72
+ requesterIp?: string;
73
+ }) => Promise<CredentialsLoginResult>;
74
+ declare const loginWithCredentialsCore: ({ email, password }: {
75
+ email: string;
76
+ password: string;
77
+ }, options?: {
78
+ supersedeToken?: string;
79
+ requesterIp?: string;
80
+ }) => Promise<CredentialsLoginResult>;
81
+ declare const loginWithCredentials: (params: LoginOrRegisterParams, options?: {
82
+ supersedeToken?: string;
83
+ requesterIp?: string;
84
+ }) => Promise<CredentialsLoginResult>;
85
+ export interface OAuthCallbackResult {
86
+ token: string;
87
+ redirectUrl: string;
88
+ userId: string;
89
+ provider: string;
90
+ isNewUser: boolean;
91
+ }
92
+ declare const loginCallback: (pathname: string, req: IncomingMessage, _res: ServerResponse, options?: {
93
+ defaultRedirectUrl?: string;
94
+ supersedeToken?: string;
95
+ }) => Promise<OAuthCallbackResult | false>;
96
+ export { loginWithCredentials, loginCallback, registerWithCredentials, loginWithCredentialsCore };
@@ -0,0 +1,7 @@
1
+ import { Socket } from "socket.io";
2
+ export declare const logout: ({ token, socket, userId, skipSessionDelete }: {
3
+ token: string | null;
4
+ socket: Socket | undefined;
5
+ userId: string | null;
6
+ skipSessionDelete?: boolean;
7
+ }) => Promise<void>;
@@ -0,0 +1,171 @@
1
+ export type OAuthUserData = Record<string, unknown>;
2
+ export declare const asOAuthUserData: (value: unknown) => OAuthUserData;
3
+ export interface CredentialsProvider {
4
+ name: 'credentials';
5
+ }
6
+ export interface FullOAuthProvider {
7
+ name: string;
8
+ clientID: string;
9
+ clientSecret: string;
10
+ callbackURL: string;
11
+ authorizationURL: string;
12
+ tokenExchangeURL: string;
13
+ tokenExchangeMethod: 'json' | 'form';
14
+ userInfoURL: string;
15
+ /**
16
+ * OAuth scopes requested at the authorization step. Consumers can extend
17
+ * a built-in provider's defaults to request additional permissions:
18
+ *
19
+ * ```ts
20
+ * const customGoogle = googleProvider({...});
21
+ * customGoogle.scope.push('https://www.googleapis.com/auth/calendar.readonly');
22
+ * ```
23
+ *
24
+ * Or pass `extraScopes` to the built-in factory (see `OAuthHelperInput`).
25
+ */
26
+ scope: string[];
27
+ nameKey: string;
28
+ emailKey: string;
29
+ /**
30
+ * Optional key on the userInfo response holding a provider "email verified"
31
+ * boolean. When set AND the field is present-and-falsy, the framework rejects
32
+ * the profile rather than trusting an unverified address — defense-in-depth
33
+ * against account-linking takeover under the `'unified'` strategy (SEC-21).
34
+ * A missing field (provider doesn't report it) is treated as "trusted",
35
+ * preserving behaviour for providers that don't expose the flag.
36
+ */
37
+ emailVerifiedKey?: string;
38
+ /**
39
+ * Set `true` for a provider whose userinfo ONLY returns an email once the
40
+ * address is confirmed with the provider (e.g. Facebook Graph `/me` omits
41
+ * unconfirmed emails). Such a provider exposes no separate verified flag, so
42
+ * the mere PRESENCE of the email is the verified signal — this opts it in so
43
+ * both first-login CREATION and cross-provider LINK treat it as verified.
44
+ * Mutually exclusive in practice with `emailVerifiedKey`.
45
+ */
46
+ emailImpliesVerified?: boolean;
47
+ avatarKey?: string;
48
+ avatarCodeKey: string;
49
+ getEmail?: (accessToken: string) => Promise<string | false | undefined>;
50
+ getAvatar?: (params: {
51
+ userData: OAuthUserData;
52
+ avatarId?: string;
53
+ accessToken: string;
54
+ }) => string | undefined | Promise<string | undefined>;
55
+ /**
56
+ * Optional per-provider hook to attach extra runtime-only data to the
57
+ * session (NOT to the User row). Return key/value pairs that get merged
58
+ * into `BaseSessionLayout` before `saveSession`. To get strict typing
59
+ * downstream, extend `BaseSessionLayout` via module augmentation:
60
+ *
61
+ * ```ts
62
+ * declare module '@luckystack/core' {
63
+ * interface BaseSessionLayout {
64
+ * googleCalendarToken?: string;
65
+ * }
66
+ * }
67
+ * ```
68
+ *
69
+ * Data lives on the session in Redis and disappears on logout — no Prisma
70
+ * schema change required. Don't store secrets you wouldn't want surfaced
71
+ * to a user inspecting their own session.
72
+ */
73
+ extraSessionFields?: (params: {
74
+ userData: OAuthUserData;
75
+ accessToken: string;
76
+ }) => Promise<Record<string, unknown>> | Record<string, unknown>;
77
+ /**
78
+ * Extra query params appended to the authorization-redirect URL (CFG-21).
79
+ * Lets a provider request e.g. `access_type=offline`, `login_hint=...`, or
80
+ * override the framework's default `prompt=select_account`. The server's
81
+ * authorize route merges these over the built-in params (a key present here
82
+ * wins). Reserved OAuth params (`client_id`, `redirect_uri`, `scope`,
83
+ * `response_type`, `state`, `code_challenge`, `code_challenge_method`) should
84
+ * NOT be set here — they are framework-owned. Default unset.
85
+ */
86
+ extraAuthorizationParams?: Record<string, string>;
87
+ /**
88
+ * Opt this provider into PKCE (RFC 7636, S256) (F11). When `true`, the
89
+ * framework generates a `code_verifier` at flow start, stores it server-side
90
+ * with the OAuth state, sends `code_challenge`/`code_challenge_method=S256` on
91
+ * the authorize redirect, and replays the verifier at token exchange. Required
92
+ * by OAuth 2.1 / PKCE-mandating providers (X/Twitter, some Okta/Auth0
93
+ * policies). Default `false` (no PKCE — existing flows are byte-identical).
94
+ */
95
+ usePkce?: boolean;
96
+ }
97
+ export type OAuthProvider = CredentialsProvider | FullOAuthProvider;
98
+ export declare const isFullOAuthProvider: (provider: OAuthProvider) => provider is FullOAuthProvider;
99
+ export interface OAuthEndpointOverrides {
100
+ /** Override the provider's authorization URL (e.g. GitHub Enterprise host, Microsoft custom tenant). */
101
+ authorizationURL?: string;
102
+ /** Override the token exchange URL. */
103
+ tokenExchangeURL?: string;
104
+ /** Override the userInfo URL. */
105
+ userInfoURL?: string;
106
+ }
107
+ interface OAuthHelperInput {
108
+ clientId: string | undefined;
109
+ clientSecret: string | undefined;
110
+ callbackUrl: string;
111
+ /** Optional URL overrides for self-hosted / custom-tenant deployments. */
112
+ endpoints?: OAuthEndpointOverrides;
113
+ /**
114
+ * Additional OAuth scopes to request alongside the built-in defaults. Use
115
+ * for "give me access to the user's calendar / drive / repos" flows.
116
+ * Merged with the helper's default scopes; duplicates are deduplicated.
117
+ */
118
+ extraScopes?: string[];
119
+ /**
120
+ * Per-provider hook for attaching runtime-only fields to the session.
121
+ * See `FullOAuthProvider.extraSessionFields` for usage + module-augmentation
122
+ * pattern.
123
+ */
124
+ extraSessionFields?: FullOAuthProvider['extraSessionFields'];
125
+ /**
126
+ * Extra query params for the authorize redirect (CFG-21). See
127
+ * `FullOAuthProvider.extraAuthorizationParams`.
128
+ */
129
+ extraAuthorizationParams?: Record<string, string>;
130
+ /** Opt this provider into PKCE (S256) (F11). See `FullOAuthProvider.usePkce`. */
131
+ usePkce?: boolean;
132
+ }
133
+ export declare const credentialsProvider: () => CredentialsProvider;
134
+ export declare const googleProvider: (input: OAuthHelperInput) => FullOAuthProvider;
135
+ export declare const githubProvider: (input: OAuthHelperInput) => FullOAuthProvider;
136
+ export declare const discordProvider: (input: OAuthHelperInput) => FullOAuthProvider;
137
+ export interface FacebookProviderInput extends OAuthHelperInput {
138
+ /** Graph API version segment used in the URLs. Default `'v18.0'` (current as of 2026 — bump as Meta deprecates older versions). */
139
+ apiVersion?: string;
140
+ }
141
+ export declare const facebookProvider: (input: FacebookProviderInput) => FullOAuthProvider;
142
+ export interface MicrosoftProviderInput extends OAuthHelperInput {
143
+ /** Tenant id segment in the authorize/token URLs. Default `'common'`. */
144
+ tenant?: string;
145
+ /** OAuth API version segment. Default `'v2.0'`. */
146
+ apiVersion?: string;
147
+ /** Graph API version segment for /me. Default `'v1.0'`. */
148
+ graphApiVersion?: string;
149
+ /**
150
+ * Allow falling back to `userPrincipalName` as the account email when Graph
151
+ * `/me.mail` is empty (SEC-11). A UPN is NOT guaranteed routable or verified;
152
+ * under the `'unified'` account strategy a UPN equal to a victim's email would
153
+ * link accounts, and it commonly mis-keys an account on a non-deliverable
154
+ * address (reset emails never arrive). Default `false` — only enable when the
155
+ * tenant guarantees UPN == primary SMTP. When `false` and `.mail` is empty the
156
+ * provider reports "no email" and the login is rejected rather than linked.
157
+ */
158
+ allowUpnFallback?: boolean;
159
+ }
160
+ export declare const microsoftProvider: (input: MicrosoftProviderInput) => FullOAuthProvider;
161
+ export declare const getOAuthProviders: () => OAuthProvider[];
162
+ export declare const registerOAuthProviders: (providers: OAuthProvider[]) => OAuthProvider[];
163
+ /**
164
+ * Register a factory function that builds the provider list on first use.
165
+ * Prefer this over `registerOAuthProviders` from the package `register` side-
166
+ * effect so config/env reads are deferred to request time and survive any
167
+ * `registerProjectConfig` call that happens after package import.
168
+ * A subsequent `registerOAuthProviders` call replaces the factory.
169
+ */
170
+ export declare const registerOAuthProviderFactory: (factory: () => OAuthProvider[]) => void;
171
+ export {};
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Validate `password` against the active project's password policy.
3
+ * Returns null on success, a string reason-key on the first failure.
4
+ *
5
+ * The policy lives in `projectConfig.auth.passwordPolicy` and is read at
6
+ * call time, so consumers can override defaults via `registerProjectConfig`.
7
+ */
8
+ export declare const validatePassword: (password: string) => string | null;
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Create a one-time password-reset token bound to a user id. Any previously
3
+ * issued (but not yet consumed) token for the same user is invalidated first
4
+ * (LOGIN-F16). Stored in Redis with the configured TTL — only `sha256(token)`
5
+ * is persisted (hash-at-rest). Returns the raw token string — caller emails it
6
+ * to the user (typically embedded in a URL).
7
+ */
8
+ export declare const createPasswordResetToken: (userId: string) => Promise<string>;
9
+ /**
10
+ * Validate and consume a password-reset token. Returns the bound userId on
11
+ * success and removes the token (one-time use, atomic GET+DEL). Returns null
12
+ * when the token is missing, expired, malformed, or already consumed.
13
+ */
14
+ export declare const consumePasswordResetToken: (token: string) => Promise<string | null>;
15
+ /**
16
+ * Bcrypt-hash a plaintext password and write it to the user record via the
17
+ * registered user adapter. Used by the reset flow and by the settings
18
+ * password-change flow. Validates against the active password policy
19
+ * (`projectConfig.auth.passwordPolicy`) before hashing — throws a `PasswordPolicyError`
20
+ * when the plaintext fails policy. Catch and surface the error code to the
21
+ * client; framework-mode reset/change flows already do this.
22
+ *
23
+ * ADR — DD-LOGIN-F17: this primitive intentionally does NOT revoke sessions.
24
+ * Session revocation is the responsibility of each calling route because the
25
+ * right behaviour differs per use-case:
26
+ * - `reset-password/confirmReset_v1` revokes ALL sessions (`revokeUserSessions(userId, null)`)
27
+ * because a forgot-password reset means the old credential is compromised.
28
+ * - `settings/changePassword_v1` revokes every OTHER session but keeps the
29
+ * current one active (`revokeUserSessions(userId, user.token)`) so the
30
+ * user is not kicked from the device they changed the password on.
31
+ * - Custom consumers may want a third behaviour (e.g. re-use the existing
32
+ * token after an admin password-force) — keeping the primitive pure lets
33
+ * them compose it freely.
34
+ * If you call this function directly, YOU must call `revokeUserSessions`
35
+ * afterwards unless you have a deliberate reason not to.
36
+ */
37
+ export declare const updatePasswordHash: (userId: string, plaintext: string) => Promise<void>;
38
+ /**
39
+ * Thrown by `updatePasswordHash` when the supplied plaintext violates the
40
+ * active password policy. `errorCode` matches the i18n reason key the rest
41
+ * of the login flow uses (e.g. `login.passwordRequiresUppercase`).
42
+ */
43
+ export declare class PasswordPolicyError extends Error {
44
+ readonly errorCode: string;
45
+ constructor(errorCode: string);
46
+ }
47
+ /** Verify a plaintext password against a stored bcrypt hash. */
48
+ export declare const verifyPassword: (plaintext: string, hash: string) => Promise<boolean>;
@@ -0,0 +1,19 @@
1
+ export interface PostLoginRedirectInput {
2
+ userId: string;
3
+ provider: string;
4
+ isNewUser: boolean;
5
+ defaultUrl: string;
6
+ }
7
+ export type PostLoginRedirectResolver = (input: PostLoginRedirectInput) => string | Promise<string>;
8
+ export declare const registerPostLoginRedirect: (resolver: PostLoginRedirectResolver) => PostLoginRedirectResolver;
9
+ export declare const getPostLoginRedirect: () => PostLoginRedirectResolver | null;
10
+ /**
11
+ * Resolve a valid relative post-login result against the trusted absolute
12
+ * default URL supplied by the HTTP callback layer. OAuth callbacks commonly
13
+ * live on a separate backend origin; returning `/dashboard` unchanged would
14
+ * make the browser resolve it against that backend instead of the frontend.
15
+ *
16
+ * Absolute candidates and callers that supplied only a relative default stay
17
+ * byte-identical. Origin allowlisting happens in login.ts BEFORE this helper.
18
+ */
19
+ export declare const resolvePostLoginRedirectAgainstDefault: (candidate: string, defaultUrl: string) => string;
@@ -1,2 +1 @@
1
-
2
- export { }
1
+ export {};
@@ -0,0 +1,73 @@
1
+ import type { BaseSessionLayout as SessionLayout } from "./sessionLayout.js";
2
+ /**
3
+ * Save or update a user session through the active session adapter.
4
+ *
5
+ * @param token - The session token (unique identifier)
6
+ * @param data - The session data to store
7
+ * @param newUser - If true, this is a new login (triggers single-session enforcement)
8
+ * @param options.supersedeToken - A token belonging to the SAME browser that is
9
+ * being replaced by this login (e.g. re-login while already signed in). It is
10
+ * excluded from single-session enforcement so the user's current device is not
11
+ * kicked by its own new session. The caller is responsible for deleting it
12
+ * afterwards (use `deleteSession(supersedeToken, { skipSocketLogout: true })`).
13
+ */
14
+ declare const saveSession: (token: string, data: SessionLayout, newUser?: boolean, options?: {
15
+ supersedeToken?: string;
16
+ }) => Promise<{
17
+ ok: true;
18
+ } | {
19
+ ok: false;
20
+ errorCode: string;
21
+ }>;
22
+ /**
23
+ * Retrieve a user session through the active session adapter.
24
+ *
25
+ * @param token - The session token
26
+ * @param options.refresh - When false, skip the sliding-expiration TTL
27
+ * extension. Defaults to true. Pass false for read-only probes (e.g. a
28
+ * background audit, an admin lookup) where a stolen token should NOT earn
29
+ * extra lifetime just because the server touched it (LOGIN-F9).
30
+ * @returns The session data or null if not found
31
+ */
32
+ declare const getSession: (token: string | null, options?: {
33
+ refresh?: boolean;
34
+ }) => Promise<SessionLayout | null>;
35
+ /**
36
+ * Delete a user session through the active adapter and notify connected
37
+ * clients.
38
+ *
39
+ * @param token - The session token to delete
40
+ * @param options.skipSocketLogout - When true, the session data + active-token
41
+ * tracking are cleaned up and the pre/post delete hooks still fire, but NO
42
+ * `logout` is emitted to sockets in the token's room. Use this when the SAME
43
+ * browser is replacing its session (re-login): emitting a logout would bounce
44
+ * that browser to the login page and null its brand-new session.
45
+ * @returns true if successful
46
+ */
47
+ declare const deleteSession: (token: string, options?: {
48
+ skipSocketLogout?: boolean;
49
+ }) => Promise<boolean>;
50
+ /**
51
+ * Get all active sessions (admin utility). Returns empty when the active
52
+ * adapter doesn't implement enumeration (signed-JWT-stateless, log-only).
53
+ *
54
+ * @returns Array of all session data
55
+ */
56
+ declare const getAllSessions: () => Promise<SessionLayout[]>;
57
+ /**
58
+ * Revoke every active session for a user, optionally keeping one alive.
59
+ *
60
+ * @param userId - The user whose sessions to revoke
61
+ * @param exceptToken - If provided, this session is left untouched. Pass the
62
+ * caller's own token when revoking after a password change so the user
63
+ * isn't logged out of the device they just changed it from. Pass null/omit
64
+ * to revoke everything (account deletion, "sign out everywhere", etc.).
65
+ *
66
+ * Each revocation goes through `deleteSession`, so connected sockets are
67
+ * told to log out via the same flow as a normal logout. Returns the number
68
+ * of sessions actually revoked.
69
+ */
70
+ declare const revokeUserSessions: (userId: string, exceptToken?: string | null) => Promise<number>;
71
+ declare const sessionKeyFor: (token: string) => string;
72
+ declare const activeUsersKeyFor: (userId: string) => string;
73
+ export { saveSession, getSession, deleteSession, getAllSessions, revokeUserSessions, sessionKeyFor, activeUsersKeyFor };
@@ -0,0 +1,66 @@
1
+ export interface SessionAdapter {
2
+ /** Human-readable identifier (used in logs/diagnostics). */
3
+ name: string;
4
+ /** Read the raw stored session string for `token`. Null when missing. */
5
+ getRaw(token: string): Promise<string | null>;
6
+ /**
7
+ * Write the raw session string for `token` with a TTL. Implementations
8
+ * MUST honour the TTL — sessions without one would never expire.
9
+ */
10
+ setRaw(token: string, value: string, ttlSeconds: number): Promise<void>;
11
+ /** Delete the session record for `token`. Idempotent. */
12
+ delete(token: string): Promise<void>;
13
+ /**
14
+ * Refresh the TTL on an existing session record (sliding-window
15
+ * expiration). Returns true when the underlying record existed and the
16
+ * TTL was applied, false otherwise. Implementations must not create a
17
+ * record on miss.
18
+ */
19
+ expire(token: string, ttlSeconds: number): Promise<boolean>;
20
+ /** Remaining TTL in seconds (or null when the record is missing). */
21
+ ttl(token: string): Promise<number | null>;
22
+ trackActive(userId: string, token: string, ttlSeconds: number): Promise<void>;
23
+ untrackActive(userId: string, token: string): Promise<void>;
24
+ listActive(userId: string): Promise<string[]>;
25
+ /**
26
+ * Refresh the TTL on the active-tokens-per-user record WITHOUT adding a
27
+ * token (sliding-window expiration parity with the session key). Called on
28
+ * an authenticated read so a session kept alive purely by reads does not
29
+ * outlive its activeUsers entry — which would otherwise let a stolen token
30
+ * survive a sign-out-everywhere / password reset. Optional: backends whose
31
+ * active-set entries do not expire independently (or that have no TTL
32
+ * concept) can omit it. Must not create a record on miss.
33
+ */
34
+ touchActive?(userId: string, ttlSeconds: number): Promise<void>;
35
+ /**
36
+ * Admin walk: yield every active session record. Optional because
37
+ * non-scannable backends (signed-JWT-stateless, log-only) cannot
38
+ * enumerate. Callers fall back to a per-user listActive when omitted.
39
+ *
40
+ * **Dev/admin-only (LOGIN-F14).** Implementations scan the full keyspace and
41
+ * load every session into memory in one call — O(all sessions) in both Redis
42
+ * round-trips and memory. Do NOT call on the hot request path or from any
43
+ * user-facing API. Use `listActive(userId)` for per-user lookups.
44
+ */
45
+ listAll?(): Promise<{
46
+ token: string;
47
+ raw: string;
48
+ }[]>;
49
+ }
50
+ export declare const redisSessionAdapter: SessionAdapter;
51
+ /**
52
+ * Replace the active session storage backend. Call once at boot from
53
+ * `luckystack/server/index.ts`, BEFORE the first login request. Common
54
+ * use cases:
55
+ *
56
+ * - DynamoDB / Cosmos DB for serverless / edge deployments
57
+ * - Postgres `sessions` table when you already operate Postgres
58
+ * - signed-JWT-stateless (the adapter writes nothing, reads from the
59
+ * JWT, and stores activeUsers per token in a small Redis hash)
60
+ * - in-memory mock for integration tests
61
+ *
62
+ * Last-write-wins: subsequent calls overwrite the active adapter.
63
+ */
64
+ export declare const registerSessionAdapter: (adapter: SessionAdapter) => void;
65
+ /** Read the currently-active session adapter (defaults to Redis). */
66
+ export declare const getSessionAdapter: () => SessionAdapter;
@@ -0,0 +1 @@
1
+ export type { BaseSessionLayout, SessionLocation, AuthProps } from '@luckystack/core';
@@ -0,0 +1,19 @@
1
+ import type { BaseSessionLayout } from './sessionLayout.js';
2
+ export type SessionSanitizer = (session: BaseSessionLayout) => BaseSessionLayout;
3
+ /**
4
+ * Register a redactor applied to the session record before it is persisted to
5
+ * the store and broadcast to connected clients. Call once at boot. Last write
6
+ * wins. Pass a function that returns a shallow copy with sensitive fields
7
+ * stripped, e.g. `({ totpSecret, stripeCustomerId, ...safe }) => safe`.
8
+ */
9
+ export declare const registerSessionSanitizer: (sanitizer: SessionSanitizer) => void;
10
+ /** Read the active sanitizer (or `null` when none is registered). */
11
+ export declare const getSessionSanitizer: () => SessionSanitizer | null;
12
+ /**
13
+ * Apply the registered sanitizer (no-op identity when none registered). Used by
14
+ * `saveSession`. Defensive: a throwing sanitizer must not break login, so the
15
+ * caller falls back to the un-sanitized record on error (logged by the caller).
16
+ */
17
+ export declare const applySessionSanitizer: (session: BaseSessionLayout) => BaseSessionLayout;
18
+ /** Test-only: clear the registered sanitizer between scenarios. */
19
+ export declare const resetSessionSanitizerForTests: () => void;
package/dist/totp.d.ts ADDED
@@ -0,0 +1,38 @@
1
+ export declare const base32Encode: (input: Buffer) => string;
2
+ export declare const base32Decode: (input: string) => Buffer | null;
3
+ export declare const hotp: (key: Buffer, counter: number, digits?: number) => string;
4
+ export interface TotpVerifyInput {
5
+ /** base32-encoded shared secret (as provisioned to the authenticator app). */
6
+ secret: string;
7
+ /** The user-entered code (digits; surrounding whitespace tolerated). */
8
+ code: string;
9
+ /**
10
+ * Accepted clock drift in 30s steps on EITHER side of "now" (default 1 —
11
+ * i.e. the previous, current and next code are accepted; RFC 6238 §5.2
12
+ * recommends at most one backward step).
13
+ */
14
+ window?: number;
15
+ /** Injection point for tests; defaults to the real clock. */
16
+ timestampMs?: number;
17
+ }
18
+ export interface TotpVerifyResult {
19
+ valid: boolean;
20
+ /**
21
+ * The timestep the code matched (only when valid). Persist the highest
22
+ * accepted value per user and refuse `timestep <= lastAccepted` to make
23
+ * every code single-use (replay protection across the drift window).
24
+ */
25
+ timestep: number | null;
26
+ }
27
+ export declare const verifyTotp: ({ secret, code, window, timestampMs }: TotpVerifyInput) => TotpVerifyResult;
28
+ /** 20 random bytes (RFC 4226 §4 minimum: 128 bits; 160 recommended), base32. */
29
+ export declare const generateTotpSecret: () => string;
30
+ export interface OtpauthUriInput {
31
+ /** base32 secret from generateTotpSecret(). */
32
+ secret: string;
33
+ /** What the authenticator app shows as the account (usually the user's email). */
34
+ accountName: string;
35
+ /** App/brand name shown above the account (defaults to the project title). */
36
+ issuer: string;
37
+ }
38
+ export declare const buildOtpauthUri: ({ secret, accountName, issuer }: OtpauthUriInput) => string;
@@ -0,0 +1,60 @@
1
+ import { type UserRecord } from './userAdapter.js';
2
+ import { type CredentialsLoginChallenge, type CredentialsLoginResult, type TwoFactorMethod } from './login.js';
3
+ export declare const encryptTotpSecret: (secret: string) => string;
4
+ export declare const decryptTotpSecret: (stored: string) => string | null;
5
+ export declare const availableTwoFactorMethods: (user: UserRecord) => TwoFactorMethod[];
6
+ export declare const createTwoFactorChallengeIfRequired: (user: UserRecord, _context: {
7
+ requesterIp?: string;
8
+ }) => Promise<CredentialsLoginChallenge | null>;
9
+ export interface VerifyTwoFactorInput {
10
+ challengeToken: string;
11
+ code: string;
12
+ /** Defaults to 'totp'; the client sends what the user picked. */
13
+ method?: TwoFactorMethod;
14
+ supersedeToken?: string;
15
+ requesterIp?: string;
16
+ }
17
+ export declare const verifyTwoFactorChallenge: (input: VerifyTwoFactorInput) => Promise<CredentialsLoginResult>;
18
+ export declare const requestTwoFactorEmailCode: (challengeToken: string) => Promise<{
19
+ ok: boolean;
20
+ reason?: string;
21
+ }>;
22
+ export declare const sendCodeEmail: (to: string, code: string, subject: string, intro: string) => Promise<boolean>;
23
+ export type TotpEnrollmentStart = {
24
+ ok: true;
25
+ secret: string;
26
+ otpauthUri: string;
27
+ } | {
28
+ ok: false;
29
+ reason: string;
30
+ };
31
+ /**
32
+ * Step 1: mint a secret and park it as PENDING (Redis, 10 min) — the user
33
+ * record is untouched until the user proves the app works via
34
+ * `confirmTotpEnrollment`. Re-calling replaces the pending secret. Refused for
35
+ * an already-enrolled user / a disabled feature (see enrollmentBlockedReason).
36
+ */
37
+ export declare const beginTotpEnrollment: (user: UserRecord) => Promise<TotpEnrollmentStart>;
38
+ export type ConfirmTotpEnrollmentResult = {
39
+ ok: true;
40
+ recoveryCodes: string[];
41
+ } | {
42
+ ok: false;
43
+ reason: string;
44
+ };
45
+ /**
46
+ * Step 2: the user enters the first code from their app. On success the
47
+ * secret + recovery-code hashes land on the user record and 2FA is ON.
48
+ * The RAW recovery codes are returned exactly once — show + let them save.
49
+ */
50
+ export declare const confirmTotpEnrollment: (user: UserRecord, code: string) => Promise<ConfirmTotpEnrollmentResult>;
51
+ /**
52
+ * Disable 2FA. Requires a currently-valid TOTP code OR an unused recovery
53
+ * code, so a hijacked (but 2FA-less) session can't silently strip the factor.
54
+ */
55
+ export declare const disableTwoFactor: (user: UserRecord, code: string) => Promise<{
56
+ ok: boolean;
57
+ reason?: string;
58
+ }>;
59
+ /** Replace the recovery-code set (requires a valid current TOTP code). */
60
+ export declare const regenerateRecoveryCodes: (user: UserRecord, code: string) => Promise<ConfirmTotpEnrollmentResult>;