@luckystack/login 0.10.2 → 0.11.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/AGENTS.md +7 -0
- package/CHANGELOG.md +6 -1
- package/README.md +7 -235
- package/dist/accountStrategy.d.ts +12 -0
- package/dist/authLockout.d.ts +29 -0
- package/dist/data/commonPasswords.d.ts +2 -0
- package/dist/emailChange.d.ts +21 -0
- package/dist/emailChangeNotification.d.ts +24 -0
- package/dist/emailCodeLogin.d.ts +19 -0
- package/dist/emailModuleLoader.d.ts +13 -0
- package/dist/emailOtp.d.ts +33 -0
- package/dist/forgotPassword.d.ts +21 -0
- package/dist/hookPayloads.d.ts +151 -0
- package/dist/index.d.ts +33 -951
- package/dist/login.d.ts +96 -0
- package/dist/logout.d.ts +7 -0
- package/dist/oauthProviders.d.ts +171 -0
- package/dist/passwordPolicy.d.ts +8 -0
- package/dist/passwordReset.d.ts +48 -0
- package/dist/redirectResolver.d.ts +19 -0
- package/dist/register.d.ts +1 -2
- package/dist/session.d.ts +73 -0
- package/dist/sessionAdapter.d.ts +66 -0
- package/dist/sessionLayout.d.ts +1 -0
- package/dist/sessionSanitizer.d.ts +19 -0
- package/dist/totp.d.ts +38 -0
- package/dist/twoFactor.d.ts +60 -0
- package/dist/userAdapter.d.ts +68 -0
- package/docs/credentials-auth.md +3 -217
- package/docs/hooks.md +3 -442
- package/docs/logout-flow.md +3 -233
- package/docs/oauth-providers.md +2 -437
- package/docs/password-reset.md +2 -332
- package/docs/redirect-validation.md +4 -389
- package/docs/session-management.md +3 -282
- package/docs/user-adapter.md +2 -350
- package/package.json +4 -4
- package/CLAUDE.md +0 -197
package/dist/login.d.ts
ADDED
|
@@ -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 };
|
package/dist/logout.d.ts
ADDED
|
@@ -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;
|
package/dist/register.d.ts
CHANGED
|
@@ -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>;
|