@spfn/auth 0.3.0-beta.23 → 0.3.0-beta.25
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/README.md +328 -7
- package/dist/client-proof.d.ts +10 -1
- package/dist/client-proof.js +136 -11
- package/dist/client-proof.js.map +1 -1
- package/dist/client.d.ts +101 -1
- package/dist/client.js +65 -0
- package/dist/client.js.map +1 -1
- package/dist/config.d.ts +122 -0
- package/dist/config.js +53 -0
- package/dist/config.js.map +1 -1
- package/dist/crypto.d.ts +1 -1
- package/dist/errors.d.ts +159 -3
- package/dist/errors.js +95 -2
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +52 -12
- package/dist/index.js +104 -3
- package/dist/index.js.map +1 -1
- package/dist/{machine-principals-CaEFq61K.d.ts → machine-principals-CdEgxOB1.d.ts} +2049 -771
- package/dist/nextjs/api.js +329 -37
- package/dist/nextjs/api.js.map +1 -1
- package/dist/nextjs/server.d.ts +59 -24
- package/dist/nextjs/server.js +105 -11
- package/dist/nextjs/server.js.map +1 -1
- package/dist/server.d.ts +415 -332
- package/dist/server.js +2933 -1545
- package/dist/server.js.map +1 -1
- package/dist/{session-Dfwu5g2W.d.ts → session-BbhAGZtA.d.ts} +57 -1
- package/dist/{types-DYyhze28.d.ts → types-CTdoTOxM.d.ts} +24 -1
- package/migrations/20260918184037_happy_mordo/migration.sql +4 -0
- package/migrations/20260918184037_happy_mordo/snapshot.json +6000 -0
- package/migrations/20260918184152_dear_rictor/migration.sql +3 -0
- package/migrations/20260918184152_dear_rictor/snapshot.json +6039 -0
- package/migrations/20260919023107_even_mikhail_rasputin/migration.sql +20 -0
- package/migrations/20260919023107_even_mikhail_rasputin/snapshot.json +6300 -0
- package/package.json +1 -1
package/dist/client.d.ts
CHANGED
|
@@ -89,5 +89,105 @@ interface SignInWithPasskeyValue {
|
|
|
89
89
|
* private key.
|
|
90
90
|
*/
|
|
91
91
|
declare function signInWithPasskey(api: AuthApi, options?: SignInWithPasskeyOptions): Promise<PasskeyResult<SignInWithPasskeyValue>>;
|
|
92
|
+
interface RenewSessionValue {
|
|
93
|
+
/** The new device key the session now runs on. */
|
|
94
|
+
keyId: string;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Renew a bound session key with a passkey assertion.
|
|
98
|
+
*
|
|
99
|
+
* What an app calls when a request came back `SessionRenewalRequiredError`: the
|
|
100
|
+
* session's short-lived key has run out and one WebAuthn ceremony puts a new one
|
|
101
|
+
* in the cookie. The person sees the system prompt, not a sign-in form.
|
|
102
|
+
*
|
|
103
|
+
* The body is `{ response }` and nothing else. The expiring key's id lives in an
|
|
104
|
+
* HttpOnly cookie that page script cannot read, and the new key pair is the
|
|
105
|
+
* Next.js proxy's to generate — both are injected there, exactly as they are for
|
|
106
|
+
* `signInWithPasskey`. Nothing here handles a private key.
|
|
107
|
+
*
|
|
108
|
+
* Refusals from the server are rejected promises rather than results, on the same
|
|
109
|
+
* rule the rest of this file follows: a `SessionRenewalRefusedError` means the
|
|
110
|
+
* server declined — the key is past its grace, or was revoked — and the app's
|
|
111
|
+
* answer is to send the person to sign in, which is not the same as the ceremony
|
|
112
|
+
* failing.
|
|
113
|
+
*/
|
|
114
|
+
declare function renewSession(api: AuthApi): Promise<PasskeyResult<RenewSessionValue>>;
|
|
115
|
+
/** What a successful disable answers with — the mode the account is now in. */
|
|
116
|
+
interface DisableSessionBindingValue {
|
|
117
|
+
mode: 'none';
|
|
118
|
+
}
|
|
119
|
+
interface DisableSessionBindingOptions {
|
|
120
|
+
/**
|
|
121
|
+
* The account password, for a browser with no passkey to hand.
|
|
122
|
+
*
|
|
123
|
+
* Send it, or let the ceremony run — one of the two is required. Key age is
|
|
124
|
+
* deliberately not accepted: a session cookie copied in the minutes after a
|
|
125
|
+
* sign-in carries exactly that, and it must not be able to switch the
|
|
126
|
+
* protection off.
|
|
127
|
+
*/
|
|
128
|
+
currentPassword?: string;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Turn session binding off for this account.
|
|
132
|
+
*
|
|
133
|
+
* With `currentPassword` this is one call. Without it, the passkey ceremony runs
|
|
134
|
+
* first and the assertion is what proves ownership — the same ceremony renewal
|
|
135
|
+
* uses, for the same reason.
|
|
136
|
+
*
|
|
137
|
+
* Turning binding *on* needs no ceremony and no helper: it is
|
|
138
|
+
* `api.setSessionBinding.call({ body: { mode: 'passkey' } })`. Leaving is the
|
|
139
|
+
* privileged direction here, which is the reverse of the usual posture and is
|
|
140
|
+
* the whole reason this helper exists.
|
|
141
|
+
*/
|
|
142
|
+
declare function disableSessionBinding(api: AuthApi, options?: DisableSessionBindingOptions): Promise<PasskeyResult<DisableSessionBindingValue>>;
|
|
143
|
+
/**
|
|
144
|
+
* What a completed step-up answers with — the sign-in the 202 was standing in for.
|
|
145
|
+
*
|
|
146
|
+
* Deliberately loose about the login fields. Through the Next.js proxy the
|
|
147
|
+
* session is already sealed by the time this resolves and the app reads it from
|
|
148
|
+
* the session cookie; a native client reads `userId` off the body. `keyId` and
|
|
149
|
+
* `challengeHash` are the proxy's business and are not repeated here.
|
|
150
|
+
*/
|
|
151
|
+
interface CompleteMfaValue {
|
|
152
|
+
userId?: string;
|
|
153
|
+
keyId: string;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Finish a sign-in that answered `mfaRequired` with a code from the
|
|
157
|
+
* authenticator app.
|
|
158
|
+
*
|
|
159
|
+
* The challenge is the `secret` from that 202 — or, on the web OAuth path, the
|
|
160
|
+
* `?challenge=` the callback handler put on the confirm page's URL. It is single
|
|
161
|
+
* use and lives ten minutes.
|
|
162
|
+
*
|
|
163
|
+
* A wrong code is a rejected promise carrying `MfaVerificationFailedError`, not
|
|
164
|
+
* a result: it is the server declining rather than a ceremony failing, and there
|
|
165
|
+
* is no ceremony here at all. Five wrong ones end the challenge and the person
|
|
166
|
+
* signs in again.
|
|
167
|
+
*
|
|
168
|
+
* @param api - the typed auth client
|
|
169
|
+
* @param challenge - the challenge secret from the 202 or the callback query
|
|
170
|
+
* @param code - the six digits, spaces and dashes and all
|
|
171
|
+
*/
|
|
172
|
+
declare function completeMfaWithCode(api: AuthApi, challenge: string, code: string): Promise<CompleteMfaValue>;
|
|
173
|
+
/**
|
|
174
|
+
* Finish the same sign-in with one of the ten written-down recovery codes.
|
|
175
|
+
*
|
|
176
|
+
* Single use, and the count in `mfa/status` drops by one — an app that warns at
|
|
177
|
+
* two remaining reads it from there after this resolves.
|
|
178
|
+
*/
|
|
179
|
+
declare function completeMfaWithRecoveryCode(api: AuthApi, challenge: string, recoveryCode: string): Promise<CompleteMfaValue>;
|
|
180
|
+
/**
|
|
181
|
+
* Finish the same sign-in with a passkey the owner marked as a second factor.
|
|
182
|
+
*
|
|
183
|
+
* `options` from the server, `navigator.credentials` in the browser, `verify`
|
|
184
|
+
* back — the shape every ceremony in this file has, and a discriminated union
|
|
185
|
+
* rather than a throw for the same reason: a person who dismisses the system
|
|
186
|
+
* sheet has not hit an application error.
|
|
187
|
+
*
|
|
188
|
+
* The challenge is what names the account, since there is no session yet. A
|
|
189
|
+
* passkey the owner never marked is refused with the same body as a wrong code.
|
|
190
|
+
*/
|
|
191
|
+
declare function completeMfaWithPasskey(api: AuthApi, challenge: string): Promise<PasskeyResult<CompleteMfaValue>>;
|
|
92
192
|
|
|
93
|
-
export { type AuthApi, type EnrollPasskeyOptions, type EnrollPasskeyValue, type PasskeyFailureReason, type PasskeyResult, type SignInWithPasskeyOptions, type SignInWithPasskeyValue, enrollPasskey, isConditionalMediationAvailable, isPasskeySupported, signInWithPasskey };
|
|
193
|
+
export { type AuthApi, type CompleteMfaValue, type DisableSessionBindingOptions, type DisableSessionBindingValue, type EnrollPasskeyOptions, type EnrollPasskeyValue, type PasskeyFailureReason, type PasskeyResult, type RenewSessionValue, type SignInWithPasskeyOptions, type SignInWithPasskeyValue, completeMfaWithCode, completeMfaWithPasskey, completeMfaWithRecoveryCode, disableSessionBinding, enrollPasskey, isConditionalMediationAvailable, isPasskeySupported, renewSession, signInWithPasskey };
|
package/dist/client.js
CHANGED
|
@@ -50,10 +50,75 @@ async function signInWithPasskey(api, options = {}) {
|
|
|
50
50
|
});
|
|
51
51
|
return { ok: true, ...session };
|
|
52
52
|
}
|
|
53
|
+
async function renewSession(api) {
|
|
54
|
+
if (!isPasskeySupported()) {
|
|
55
|
+
return { ok: false, reason: "unsupported" };
|
|
56
|
+
}
|
|
57
|
+
const optionsJSON = await api.sessionRenewOptions.call({
|
|
58
|
+
body: {}
|
|
59
|
+
});
|
|
60
|
+
let response;
|
|
61
|
+
try {
|
|
62
|
+
response = await startAuthentication({ optionsJSON });
|
|
63
|
+
} catch (error) {
|
|
64
|
+
return { ok: false, reason: failureReason(error, "no-credential"), error };
|
|
65
|
+
}
|
|
66
|
+
const renewed = await api.sessionRenewVerify.call({
|
|
67
|
+
body: { response }
|
|
68
|
+
});
|
|
69
|
+
return { ok: true, keyId: renewed.keyId };
|
|
70
|
+
}
|
|
71
|
+
async function disableSessionBinding(api, options = {}) {
|
|
72
|
+
if (options.currentPassword) {
|
|
73
|
+
await api.setSessionBinding.call({ body: { mode: "none", currentPassword: options.currentPassword } });
|
|
74
|
+
return { ok: true, mode: "none" };
|
|
75
|
+
}
|
|
76
|
+
if (!isPasskeySupported()) {
|
|
77
|
+
return { ok: false, reason: "unsupported" };
|
|
78
|
+
}
|
|
79
|
+
const optionsJSON = await api.sessionBindingDisableOptions.call({
|
|
80
|
+
body: {}
|
|
81
|
+
});
|
|
82
|
+
let response;
|
|
83
|
+
try {
|
|
84
|
+
response = await startAuthentication({ optionsJSON });
|
|
85
|
+
} catch (error) {
|
|
86
|
+
return { ok: false, reason: failureReason(error, "no-credential"), error };
|
|
87
|
+
}
|
|
88
|
+
await api.setSessionBinding.call({ body: { mode: "none", response } });
|
|
89
|
+
return { ok: true, mode: "none" };
|
|
90
|
+
}
|
|
91
|
+
async function completeMfaWithCode(api, challenge, code) {
|
|
92
|
+
return await api.mfaVerify.call({ body: { challenge, code } });
|
|
93
|
+
}
|
|
94
|
+
async function completeMfaWithRecoveryCode(api, challenge, recoveryCode) {
|
|
95
|
+
return await api.mfaVerify.call({ body: { challenge, recoveryCode } });
|
|
96
|
+
}
|
|
97
|
+
async function completeMfaWithPasskey(api, challenge) {
|
|
98
|
+
if (!isPasskeySupported()) {
|
|
99
|
+
return { ok: false, reason: "unsupported" };
|
|
100
|
+
}
|
|
101
|
+
const optionsJSON = await api.mfaVerifyOptions.call({
|
|
102
|
+
body: { challenge }
|
|
103
|
+
});
|
|
104
|
+
let response;
|
|
105
|
+
try {
|
|
106
|
+
response = await startAuthentication({ optionsJSON });
|
|
107
|
+
} catch (error) {
|
|
108
|
+
return { ok: false, reason: failureReason(error, "no-credential"), error };
|
|
109
|
+
}
|
|
110
|
+
const verified = await api.mfaVerify.call({ body: { challenge, response } });
|
|
111
|
+
return { ok: true, ...verified };
|
|
112
|
+
}
|
|
53
113
|
export {
|
|
114
|
+
completeMfaWithCode,
|
|
115
|
+
completeMfaWithPasskey,
|
|
116
|
+
completeMfaWithRecoveryCode,
|
|
117
|
+
disableSessionBinding,
|
|
54
118
|
enrollPasskey,
|
|
55
119
|
isConditionalMediationAvailable,
|
|
56
120
|
isPasskeySupported,
|
|
121
|
+
renewSession,
|
|
57
122
|
signInWithPasskey
|
|
58
123
|
};
|
|
59
124
|
//# sourceMappingURL=client.js.map
|
package/dist/client.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/client/passkeys.ts"],"sourcesContent":["/**\n * @spfn/auth/client - Passkeys (WebAuthn)\n *\n * Four browser helpers over the two ceremonies. Each one is `options` from the\n * server, `navigator.credentials` in the browser, `verify` back to the server —\n * and each answers with a discriminated union instead of throwing.\n *\n * That is the whole point of this file. A person closing the system passkey\n * sheet raises `NotAllowedError`, and so does a person whose authenticator has\n * nothing to offer; neither is an application error, and code that has to tell\n * them apart by catching and re-reading `error.name` gets it wrong once and\n * shows a red banner to someone who simply changed their mind.\n *\n * Ships to browsers: no Node built-ins here, `Buffer` included. The base64url\n * helpers come from `@simplewebauthn/browser`, which is bundled into this entry.\n */\n\nimport {\n browserSupportsWebAuthn,\n browserSupportsWebAuthnAutofill,\n startAuthentication,\n startRegistration,\n type AuthenticationResponseJSON,\n type PublicKeyCredentialCreationOptionsJSON,\n type PublicKeyCredentialRequestOptionsJSON,\n type RegistrationResponseJSON,\n} from '@simplewebauthn/browser';\n\nimport type { authApi } from '@spfn/auth';\n\n/** The typed auth client these helpers drive. */\nexport type AuthApi = typeof authApi;\n\n/**\n * Why a ceremony did not produce a session.\n *\n * - `unsupported`: this browser has no WebAuthn at all\n * - `cancelled`: the person dismissed the prompt\n * - `no-credential`: the authenticator had nothing for this relying party\n * - `error`: anything else, with the original error attached\n */\nexport type PasskeyFailureReason = 'unsupported' | 'cancelled' | 'no-credential' | 'error';\n\nexport type PasskeyResult<T> =\n | ({ ok: true } & T)\n | { ok: false; reason: PasskeyFailureReason; error?: unknown };\n\n/** Whether this browser can run a WebAuthn ceremony at all. */\nexport function isPasskeySupported(): boolean\n{\n return browserSupportsWebAuthn();\n}\n\n/**\n * Whether the browser can offer passkeys inside the ordinary autofill dropdown.\n *\n * Worth checking before rendering a sign-in form: conditional mediation is what\n * turns a passkey into \"tap the suggestion above the keyboard\", and where it is\n * missing the form needs a visible \"Sign in with a passkey\" button instead.\n */\nexport async function isConditionalMediationAvailable(): Promise<boolean>\n{\n return await browserSupportsWebAuthnAutofill();\n}\n\n/**\n * The reason a ceremony failure should be reported as.\n *\n * `NotAllowedError` is the browser's answer both to \"the person said no\" and to\n * \"nothing here matched\", and the specification deliberately does not\n * distinguish them — telling a caller which applied would say whether a\n * credential for this site exists on the device. So it is `cancelled` in both\n * cases on registration, and `no-credential` on a sign-in that offered no\n * credentials to choose from, which is the reading a UI wants.\n */\nfunction failureReason(error: unknown, whenNotAllowed: PasskeyFailureReason): PasskeyFailureReason\n{\n return (error as { name?: string } | null)?.name === 'NotAllowedError' ? whenNotAllowed : 'error';\n}\n\nexport interface EnrollPasskeyOptions\n{\n /** Owner-facing name for the passkey list, e.g. the device model. */\n label?: string;\n /** Sent when the session proved itself longer ago than the recent-auth window. */\n currentPassword?: string;\n}\n\nexport interface EnrollPasskeyValue\n{\n passkeyId: string;\n label: string | null;\n createdAt: string;\n}\n\n/**\n * Enroll a passkey on the device in front of the user.\n *\n * Requires a signed-in session. A 403 with code `RECENT_AUTH_REQUIRED` from the\n * options call means the caller should prompt for the password and try again\n * with `currentPassword`; that is a rejected promise, not a result here, because\n * it is the server declining rather than the ceremony failing.\n */\nexport async function enrollPasskey(\n api: AuthApi,\n options: EnrollPasskeyOptions = {},\n): Promise<PasskeyResult<EnrollPasskeyValue>>\n{\n if (!isPasskeySupported())\n {\n return { ok: false, reason: 'unsupported' };\n }\n\n const optionsJSON = await api.passkeyRegisterOptions.call({\n body: { currentPassword: options.currentPassword },\n }) as PublicKeyCredentialCreationOptionsJSON;\n\n let response: RegistrationResponseJSON;\n\n try\n {\n response = await startRegistration({ optionsJSON });\n }\n catch (error)\n {\n return { ok: false, reason: failureReason(error, 'cancelled'), error };\n }\n\n const enrolled = await api.passkeyRegisterVerify.call({\n body: { response, label: options.label },\n }) as EnrollPasskeyValue;\n\n return { ok: true, ...enrolled };\n}\n\nexport interface SignInWithPasskeyOptions\n{\n /**\n * Offer the passkey through the browser's autofill dropdown instead of a\n * modal. Needs an `<input autocomplete=\"username webauthn\">` on the page.\n */\n conditional?: boolean;\n deviceName?: string;\n platform?: string;\n}\n\nexport interface SignInWithPasskeyValue\n{\n userId: string;\n publicId: string;\n email?: string;\n phone?: string;\n passwordChangeRequired: boolean;\n}\n\n/**\n * Sign in with a passkey, no identifier asked for.\n *\n * The device key the session runs on is generated and stored by the Next.js\n * proxy interceptor, exactly as on a password login — nothing here handles a\n * private key.\n */\nexport async function signInWithPasskey(\n api: AuthApi,\n options: SignInWithPasskeyOptions = {},\n): Promise<PasskeyResult<SignInWithPasskeyValue>>\n{\n if (!isPasskeySupported())\n {\n return { ok: false, reason: 'unsupported' };\n }\n\n const optionsJSON = await api.passkeyLoginOptions.call({\n body: {},\n }) as PublicKeyCredentialRequestOptionsJSON;\n\n let response: AuthenticationResponseJSON;\n\n try\n {\n response = await startAuthentication({ optionsJSON, useBrowserAutofill: options.conditional === true });\n }\n catch (error)\n {\n return { ok: false, reason: failureReason(error, 'no-credential'), error };\n }\n\n const session = await api.passkeyLoginVerify.call({\n body: { response },\n }) as SignInWithPasskeyValue;\n\n return { ok: true, ...session };\n}\n"],"mappings":";AAiBA;AAAA,EACI;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OAKG;AAsBA,SAAS,qBAChB;AACI,SAAO,wBAAwB;AACnC;AASA,eAAsB,kCACtB;AACI,SAAO,MAAM,gCAAgC;AACjD;AAYA,SAAS,cAAc,OAAgB,gBACvC;AACI,SAAQ,OAAoC,SAAS,oBAAoB,iBAAiB;AAC9F;AAyBA,eAAsB,cAClB,KACA,UAAgC,CAAC,GAErC;AACI,MAAI,CAAC,mBAAmB,GACxB;AACI,WAAO,EAAE,IAAI,OAAO,QAAQ,cAAc;AAAA,EAC9C;AAEA,QAAM,cAAc,MAAM,IAAI,uBAAuB,KAAK;AAAA,IACtD,MAAM,EAAE,iBAAiB,QAAQ,gBAAgB;AAAA,EACrD,CAAC;AAED,MAAI;AAEJ,MACA;AACI,eAAW,MAAM,kBAAkB,EAAE,YAAY,CAAC;AAAA,EACtD,SACO,OACP;AACI,WAAO,EAAE,IAAI,OAAO,QAAQ,cAAc,OAAO,WAAW,GAAG,MAAM;AAAA,EACzE;AAEA,QAAM,WAAW,MAAM,IAAI,sBAAsB,KAAK;AAAA,IAClD,MAAM,EAAE,UAAU,OAAO,QAAQ,MAAM;AAAA,EAC3C,CAAC;AAED,SAAO,EAAE,IAAI,MAAM,GAAG,SAAS;AACnC;AA6BA,eAAsB,kBAClB,KACA,UAAoC,CAAC,GAEzC;AACI,MAAI,CAAC,mBAAmB,GACxB;AACI,WAAO,EAAE,IAAI,OAAO,QAAQ,cAAc;AAAA,EAC9C;AAEA,QAAM,cAAc,MAAM,IAAI,oBAAoB,KAAK;AAAA,IACnD,MAAM,CAAC;AAAA,EACX,CAAC;AAED,MAAI;AAEJ,MACA;AACI,eAAW,MAAM,oBAAoB,EAAE,aAAa,oBAAoB,QAAQ,gBAAgB,KAAK,CAAC;AAAA,EAC1G,SACO,OACP;AACI,WAAO,EAAE,IAAI,OAAO,QAAQ,cAAc,OAAO,eAAe,GAAG,MAAM;AAAA,EAC7E;AAEA,QAAM,UAAU,MAAM,IAAI,mBAAmB,KAAK;AAAA,IAC9C,MAAM,EAAE,SAAS;AAAA,EACrB,CAAC;AAED,SAAO,EAAE,IAAI,MAAM,GAAG,QAAQ;AAClC;","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/client/passkeys.ts"],"sourcesContent":["/**\n * @spfn/auth/client - Passkeys (WebAuthn)\n *\n * Four browser helpers over the two ceremonies. Each one is `options` from the\n * server, `navigator.credentials` in the browser, `verify` back to the server —\n * and each answers with a discriminated union instead of throwing.\n *\n * That is the whole point of this file. A person closing the system passkey\n * sheet raises `NotAllowedError`, and so does a person whose authenticator has\n * nothing to offer; neither is an application error, and code that has to tell\n * them apart by catching and re-reading `error.name` gets it wrong once and\n * shows a red banner to someone who simply changed their mind.\n *\n * Ships to browsers: no Node built-ins here, `Buffer` included. The base64url\n * helpers come from `@simplewebauthn/browser`, which is bundled into this entry.\n */\n\nimport {\n browserSupportsWebAuthn,\n browserSupportsWebAuthnAutofill,\n startAuthentication,\n startRegistration,\n type AuthenticationResponseJSON,\n type PublicKeyCredentialCreationOptionsJSON,\n type PublicKeyCredentialRequestOptionsJSON,\n type RegistrationResponseJSON,\n} from '@simplewebauthn/browser';\n\nimport type { authApi } from '@spfn/auth';\n\n/** The typed auth client these helpers drive. */\nexport type AuthApi = typeof authApi;\n\n/**\n * Why a ceremony did not produce a session.\n *\n * - `unsupported`: this browser has no WebAuthn at all\n * - `cancelled`: the person dismissed the prompt\n * - `no-credential`: the authenticator had nothing for this relying party\n * - `error`: anything else, with the original error attached\n */\nexport type PasskeyFailureReason = 'unsupported' | 'cancelled' | 'no-credential' | 'error';\n\nexport type PasskeyResult<T> =\n | ({ ok: true } & T)\n | { ok: false; reason: PasskeyFailureReason; error?: unknown };\n\n/** Whether this browser can run a WebAuthn ceremony at all. */\nexport function isPasskeySupported(): boolean\n{\n return browserSupportsWebAuthn();\n}\n\n/**\n * Whether the browser can offer passkeys inside the ordinary autofill dropdown.\n *\n * Worth checking before rendering a sign-in form: conditional mediation is what\n * turns a passkey into \"tap the suggestion above the keyboard\", and where it is\n * missing the form needs a visible \"Sign in with a passkey\" button instead.\n */\nexport async function isConditionalMediationAvailable(): Promise<boolean>\n{\n return await browserSupportsWebAuthnAutofill();\n}\n\n/**\n * The reason a ceremony failure should be reported as.\n *\n * `NotAllowedError` is the browser's answer both to \"the person said no\" and to\n * \"nothing here matched\", and the specification deliberately does not\n * distinguish them — telling a caller which applied would say whether a\n * credential for this site exists on the device. So it is `cancelled` in both\n * cases on registration, and `no-credential` on a sign-in that offered no\n * credentials to choose from, which is the reading a UI wants.\n */\nfunction failureReason(error: unknown, whenNotAllowed: PasskeyFailureReason): PasskeyFailureReason\n{\n return (error as { name?: string } | null)?.name === 'NotAllowedError' ? whenNotAllowed : 'error';\n}\n\nexport interface EnrollPasskeyOptions\n{\n /** Owner-facing name for the passkey list, e.g. the device model. */\n label?: string;\n /** Sent when the session proved itself longer ago than the recent-auth window. */\n currentPassword?: string;\n}\n\nexport interface EnrollPasskeyValue\n{\n passkeyId: string;\n label: string | null;\n createdAt: string;\n}\n\n/**\n * Enroll a passkey on the device in front of the user.\n *\n * Requires a signed-in session. A 403 with code `RECENT_AUTH_REQUIRED` from the\n * options call means the caller should prompt for the password and try again\n * with `currentPassword`; that is a rejected promise, not a result here, because\n * it is the server declining rather than the ceremony failing.\n */\nexport async function enrollPasskey(\n api: AuthApi,\n options: EnrollPasskeyOptions = {},\n): Promise<PasskeyResult<EnrollPasskeyValue>>\n{\n if (!isPasskeySupported())\n {\n return { ok: false, reason: 'unsupported' };\n }\n\n const optionsJSON = await api.passkeyRegisterOptions.call({\n body: { currentPassword: options.currentPassword },\n }) as PublicKeyCredentialCreationOptionsJSON;\n\n let response: RegistrationResponseJSON;\n\n try\n {\n response = await startRegistration({ optionsJSON });\n }\n catch (error)\n {\n return { ok: false, reason: failureReason(error, 'cancelled'), error };\n }\n\n const enrolled = await api.passkeyRegisterVerify.call({\n body: { response, label: options.label },\n }) as EnrollPasskeyValue;\n\n return { ok: true, ...enrolled };\n}\n\nexport interface SignInWithPasskeyOptions\n{\n /**\n * Offer the passkey through the browser's autofill dropdown instead of a\n * modal. Needs an `<input autocomplete=\"username webauthn\">` on the page.\n */\n conditional?: boolean;\n deviceName?: string;\n platform?: string;\n}\n\nexport interface SignInWithPasskeyValue\n{\n userId: string;\n publicId: string;\n email?: string;\n phone?: string;\n passwordChangeRequired: boolean;\n}\n\n/**\n * Sign in with a passkey, no identifier asked for.\n *\n * The device key the session runs on is generated and stored by the Next.js\n * proxy interceptor, exactly as on a password login — nothing here handles a\n * private key.\n */\nexport async function signInWithPasskey(\n api: AuthApi,\n options: SignInWithPasskeyOptions = {},\n): Promise<PasskeyResult<SignInWithPasskeyValue>>\n{\n if (!isPasskeySupported())\n {\n return { ok: false, reason: 'unsupported' };\n }\n\n const optionsJSON = await api.passkeyLoginOptions.call({\n body: {},\n }) as PublicKeyCredentialRequestOptionsJSON;\n\n let response: AuthenticationResponseJSON;\n\n try\n {\n response = await startAuthentication({ optionsJSON, useBrowserAutofill: options.conditional === true });\n }\n catch (error)\n {\n return { ok: false, reason: failureReason(error, 'no-credential'), error };\n }\n\n const session = await api.passkeyLoginVerify.call({\n body: { response },\n }) as SignInWithPasskeyValue;\n\n return { ok: true, ...session };\n}\n\nexport interface RenewSessionValue\n{\n /** The new device key the session now runs on. */\n keyId: string;\n}\n\n/**\n * Renew a bound session key with a passkey assertion.\n *\n * What an app calls when a request came back `SessionRenewalRequiredError`: the\n * session's short-lived key has run out and one WebAuthn ceremony puts a new one\n * in the cookie. The person sees the system prompt, not a sign-in form.\n *\n * The body is `{ response }` and nothing else. The expiring key's id lives in an\n * HttpOnly cookie that page script cannot read, and the new key pair is the\n * Next.js proxy's to generate — both are injected there, exactly as they are for\n * `signInWithPasskey`. Nothing here handles a private key.\n *\n * Refusals from the server are rejected promises rather than results, on the same\n * rule the rest of this file follows: a `SessionRenewalRefusedError` means the\n * server declined — the key is past its grace, or was revoked — and the app's\n * answer is to send the person to sign in, which is not the same as the ceremony\n * failing.\n */\nexport async function renewSession(api: AuthApi): Promise<PasskeyResult<RenewSessionValue>>\n{\n if (!isPasskeySupported())\n {\n return { ok: false, reason: 'unsupported' };\n }\n\n const optionsJSON = await api.sessionRenewOptions.call({\n body: {},\n }) as PublicKeyCredentialRequestOptionsJSON;\n\n let response: AuthenticationResponseJSON;\n\n try\n {\n response = await startAuthentication({ optionsJSON });\n }\n catch (error)\n {\n return { ok: false, reason: failureReason(error, 'no-credential'), error };\n }\n\n const renewed = await api.sessionRenewVerify.call({\n body: { response },\n }) as { keyId: string };\n\n return { ok: true, keyId: renewed.keyId };\n}\n\n/** What a successful disable answers with — the mode the account is now in. */\nexport interface DisableSessionBindingValue\n{\n mode: 'none';\n}\n\nexport interface DisableSessionBindingOptions\n{\n /**\n * The account password, for a browser with no passkey to hand.\n *\n * Send it, or let the ceremony run — one of the two is required. Key age is\n * deliberately not accepted: a session cookie copied in the minutes after a\n * sign-in carries exactly that, and it must not be able to switch the\n * protection off.\n */\n currentPassword?: string;\n}\n\n/**\n * Turn session binding off for this account.\n *\n * With `currentPassword` this is one call. Without it, the passkey ceremony runs\n * first and the assertion is what proves ownership — the same ceremony renewal\n * uses, for the same reason.\n *\n * Turning binding *on* needs no ceremony and no helper: it is\n * `api.setSessionBinding.call({ body: { mode: 'passkey' } })`. Leaving is the\n * privileged direction here, which is the reverse of the usual posture and is\n * the whole reason this helper exists.\n */\nexport async function disableSessionBinding(\n api: AuthApi,\n options: DisableSessionBindingOptions = {},\n): Promise<PasskeyResult<DisableSessionBindingValue>>\n{\n if (options.currentPassword)\n {\n await api.setSessionBinding.call({ body: { mode: 'none', currentPassword: options.currentPassword } });\n\n return { ok: true, mode: 'none' };\n }\n\n if (!isPasskeySupported())\n {\n return { ok: false, reason: 'unsupported' };\n }\n\n const optionsJSON = await api.sessionBindingDisableOptions.call({\n body: {},\n }) as PublicKeyCredentialRequestOptionsJSON;\n\n let response: AuthenticationResponseJSON;\n\n try\n {\n response = await startAuthentication({ optionsJSON });\n }\n catch (error)\n {\n return { ok: false, reason: failureReason(error, 'no-credential'), error };\n }\n\n await api.setSessionBinding.call({ body: { mode: 'none', response } });\n\n return { ok: true, mode: 'none' };\n}\n\n// ============================================================================\n// Second-factor step-up on a new device (#95)\n// ============================================================================\n\n/**\n * What a completed step-up answers with — the sign-in the 202 was standing in for.\n *\n * Deliberately loose about the login fields. Through the Next.js proxy the\n * session is already sealed by the time this resolves and the app reads it from\n * the session cookie; a native client reads `userId` off the body. `keyId` and\n * `challengeHash` are the proxy's business and are not repeated here.\n */\nexport interface CompleteMfaValue\n{\n userId?: string;\n keyId: string;\n}\n\n/**\n * Finish a sign-in that answered `mfaRequired` with a code from the\n * authenticator app.\n *\n * The challenge is the `secret` from that 202 — or, on the web OAuth path, the\n * `?challenge=` the callback handler put on the confirm page's URL. It is single\n * use and lives ten minutes.\n *\n * A wrong code is a rejected promise carrying `MfaVerificationFailedError`, not\n * a result: it is the server declining rather than a ceremony failing, and there\n * is no ceremony here at all. Five wrong ones end the challenge and the person\n * signs in again.\n *\n * @param api - the typed auth client\n * @param challenge - the challenge secret from the 202 or the callback query\n * @param code - the six digits, spaces and dashes and all\n */\nexport async function completeMfaWithCode(\n api: AuthApi,\n challenge: string,\n code: string,\n): Promise<CompleteMfaValue>\n{\n return await api.mfaVerify.call({ body: { challenge, code } }) as CompleteMfaValue;\n}\n\n/**\n * Finish the same sign-in with one of the ten written-down recovery codes.\n *\n * Single use, and the count in `mfa/status` drops by one — an app that warns at\n * two remaining reads it from there after this resolves.\n */\nexport async function completeMfaWithRecoveryCode(\n api: AuthApi,\n challenge: string,\n recoveryCode: string,\n): Promise<CompleteMfaValue>\n{\n return await api.mfaVerify.call({ body: { challenge, recoveryCode } }) as CompleteMfaValue;\n}\n\n/**\n * Finish the same sign-in with a passkey the owner marked as a second factor.\n *\n * `options` from the server, `navigator.credentials` in the browser, `verify`\n * back — the shape every ceremony in this file has, and a discriminated union\n * rather than a throw for the same reason: a person who dismisses the system\n * sheet has not hit an application error.\n *\n * The challenge is what names the account, since there is no session yet. A\n * passkey the owner never marked is refused with the same body as a wrong code.\n */\nexport async function completeMfaWithPasskey(\n api: AuthApi,\n challenge: string,\n): Promise<PasskeyResult<CompleteMfaValue>>\n{\n if (!isPasskeySupported())\n {\n return { ok: false, reason: 'unsupported' };\n }\n\n const optionsJSON = await api.mfaVerifyOptions.call({\n body: { challenge },\n }) as PublicKeyCredentialRequestOptionsJSON;\n\n let response: AuthenticationResponseJSON;\n\n try\n {\n response = await startAuthentication({ optionsJSON });\n }\n catch (error)\n {\n return { ok: false, reason: failureReason(error, 'no-credential'), error };\n }\n\n const verified = await api.mfaVerify.call({ body: { challenge, response } }) as CompleteMfaValue;\n\n return { ok: true, ...verified };\n}\n"],"mappings":"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","names":[]}
|
package/dist/config.d.ts
CHANGED
|
@@ -376,6 +376,67 @@ declare const authEnvSchema: {
|
|
|
376
376
|
} & {
|
|
377
377
|
key: "SPFN_AUTH_MFA_STEP_UP_MINUTES";
|
|
378
378
|
};
|
|
379
|
+
SPFN_AUTH_MFA_CONFIRM_PATH: {
|
|
380
|
+
description: string;
|
|
381
|
+
default: string;
|
|
382
|
+
required: boolean;
|
|
383
|
+
examples: string[];
|
|
384
|
+
type: "string";
|
|
385
|
+
validator: (value: string) => string;
|
|
386
|
+
} & {
|
|
387
|
+
key: "SPFN_AUTH_MFA_CONFIRM_PATH";
|
|
388
|
+
};
|
|
389
|
+
SPFN_AUTH_MFA_CHALLENGE_TTL_MINUTES: {
|
|
390
|
+
description: string;
|
|
391
|
+
default: number;
|
|
392
|
+
required: boolean;
|
|
393
|
+
examples: number[];
|
|
394
|
+
type: "number";
|
|
395
|
+
validator: (value: string) => number;
|
|
396
|
+
} & {
|
|
397
|
+
key: "SPFN_AUTH_MFA_CHALLENGE_TTL_MINUTES";
|
|
398
|
+
};
|
|
399
|
+
SPFN_AUTH_BOUND_KEY_TTL_HOURS: {
|
|
400
|
+
description: string;
|
|
401
|
+
default: number;
|
|
402
|
+
required: boolean;
|
|
403
|
+
examples: number[];
|
|
404
|
+
type: "number";
|
|
405
|
+
validator: (value: string) => number;
|
|
406
|
+
} & {
|
|
407
|
+
key: "SPFN_AUTH_BOUND_KEY_TTL_HOURS";
|
|
408
|
+
};
|
|
409
|
+
SPFN_AUTH_BOUND_KEY_RENEW_GRACE_HOURS: {
|
|
410
|
+
description: string;
|
|
411
|
+
default: number;
|
|
412
|
+
required: boolean;
|
|
413
|
+
examples: number[];
|
|
414
|
+
type: "number";
|
|
415
|
+
validator: (value: string) => number;
|
|
416
|
+
} & {
|
|
417
|
+
key: "SPFN_AUTH_BOUND_KEY_RENEW_GRACE_HOURS";
|
|
418
|
+
};
|
|
419
|
+
SPFN_AUTH_CONCURRENT_USE_WINDOW_MS: {
|
|
420
|
+
description: string;
|
|
421
|
+
default: number;
|
|
422
|
+
required: boolean;
|
|
423
|
+
examples: number[];
|
|
424
|
+
type: "number";
|
|
425
|
+
validator: (value: string) => number;
|
|
426
|
+
} & {
|
|
427
|
+
key: "SPFN_AUTH_CONCURRENT_USE_WINDOW_MS";
|
|
428
|
+
};
|
|
429
|
+
SPFN_AUTH_SESSION_RENEW_PATH: {
|
|
430
|
+
description: string;
|
|
431
|
+
default: string;
|
|
432
|
+
required: boolean;
|
|
433
|
+
nextjs: boolean;
|
|
434
|
+
examples: string[];
|
|
435
|
+
type: "string";
|
|
436
|
+
validator: (value: string) => string;
|
|
437
|
+
} & {
|
|
438
|
+
key: "SPFN_AUTH_SESSION_RENEW_PATH";
|
|
439
|
+
};
|
|
379
440
|
SPFN_API_URL: {
|
|
380
441
|
description: string;
|
|
381
442
|
default: string;
|
|
@@ -980,6 +1041,67 @@ declare const env: _spfn_core_env.InferEnvType<{
|
|
|
980
1041
|
} & {
|
|
981
1042
|
key: "SPFN_AUTH_MFA_STEP_UP_MINUTES";
|
|
982
1043
|
};
|
|
1044
|
+
SPFN_AUTH_MFA_CONFIRM_PATH: {
|
|
1045
|
+
description: string;
|
|
1046
|
+
default: string;
|
|
1047
|
+
required: boolean;
|
|
1048
|
+
examples: string[];
|
|
1049
|
+
type: "string";
|
|
1050
|
+
validator: (value: string) => string;
|
|
1051
|
+
} & {
|
|
1052
|
+
key: "SPFN_AUTH_MFA_CONFIRM_PATH";
|
|
1053
|
+
};
|
|
1054
|
+
SPFN_AUTH_MFA_CHALLENGE_TTL_MINUTES: {
|
|
1055
|
+
description: string;
|
|
1056
|
+
default: number;
|
|
1057
|
+
required: boolean;
|
|
1058
|
+
examples: number[];
|
|
1059
|
+
type: "number";
|
|
1060
|
+
validator: (value: string) => number;
|
|
1061
|
+
} & {
|
|
1062
|
+
key: "SPFN_AUTH_MFA_CHALLENGE_TTL_MINUTES";
|
|
1063
|
+
};
|
|
1064
|
+
SPFN_AUTH_BOUND_KEY_TTL_HOURS: {
|
|
1065
|
+
description: string;
|
|
1066
|
+
default: number;
|
|
1067
|
+
required: boolean;
|
|
1068
|
+
examples: number[];
|
|
1069
|
+
type: "number";
|
|
1070
|
+
validator: (value: string) => number;
|
|
1071
|
+
} & {
|
|
1072
|
+
key: "SPFN_AUTH_BOUND_KEY_TTL_HOURS";
|
|
1073
|
+
};
|
|
1074
|
+
SPFN_AUTH_BOUND_KEY_RENEW_GRACE_HOURS: {
|
|
1075
|
+
description: string;
|
|
1076
|
+
default: number;
|
|
1077
|
+
required: boolean;
|
|
1078
|
+
examples: number[];
|
|
1079
|
+
type: "number";
|
|
1080
|
+
validator: (value: string) => number;
|
|
1081
|
+
} & {
|
|
1082
|
+
key: "SPFN_AUTH_BOUND_KEY_RENEW_GRACE_HOURS";
|
|
1083
|
+
};
|
|
1084
|
+
SPFN_AUTH_CONCURRENT_USE_WINDOW_MS: {
|
|
1085
|
+
description: string;
|
|
1086
|
+
default: number;
|
|
1087
|
+
required: boolean;
|
|
1088
|
+
examples: number[];
|
|
1089
|
+
type: "number";
|
|
1090
|
+
validator: (value: string) => number;
|
|
1091
|
+
} & {
|
|
1092
|
+
key: "SPFN_AUTH_CONCURRENT_USE_WINDOW_MS";
|
|
1093
|
+
};
|
|
1094
|
+
SPFN_AUTH_SESSION_RENEW_PATH: {
|
|
1095
|
+
description: string;
|
|
1096
|
+
default: string;
|
|
1097
|
+
required: boolean;
|
|
1098
|
+
nextjs: boolean;
|
|
1099
|
+
examples: string[];
|
|
1100
|
+
type: "string";
|
|
1101
|
+
validator: (value: string) => string;
|
|
1102
|
+
} & {
|
|
1103
|
+
key: "SPFN_AUTH_SESSION_RENEW_PATH";
|
|
1104
|
+
};
|
|
983
1105
|
SPFN_API_URL: {
|
|
984
1106
|
description: string;
|
|
985
1107
|
default: string;
|
package/dist/config.js
CHANGED
|
@@ -360,6 +360,59 @@ var authEnvSchema = defineEnvSchema({
|
|
|
360
360
|
examples: [5, 10, 30]
|
|
361
361
|
})
|
|
362
362
|
},
|
|
363
|
+
SPFN_AUTH_MFA_CONFIRM_PATH: {
|
|
364
|
+
...envString({
|
|
365
|
+
description: "App page that asks for the second factor after a 202 sign-in, as a path on {NEXT_PUBLIC_SPFN_APP_URL || SPFN_APP_URL}. createOAuthCallbackHandler redirects the browser there with ?challenge= when a social sign-in needs a step-up; the page posts that challenge and a code to /_auth/mfa/verify. It is a page in your app, not an API route.",
|
|
366
|
+
default: "/auth/mfa",
|
|
367
|
+
required: false,
|
|
368
|
+
examples: ["/auth/mfa", "/sign-in/two-factor"]
|
|
369
|
+
})
|
|
370
|
+
},
|
|
371
|
+
SPFN_AUTH_MFA_CHALLENGE_TTL_MINUTES: {
|
|
372
|
+
...envNumber({
|
|
373
|
+
description: "How long a new-device second-factor challenge stays spendable. A sign-in on an enrolled account from a device it has never seen answers 202 with a challenge instead of a session, and the key it registered is inactive until POST /_auth/mfa/verify spends it. This is how long the person has to reach for their authenticator \u2014 and how long an attacker holding only the password has. The Next.js proxy seals its pending cookie for the same span.",
|
|
374
|
+
default: 10,
|
|
375
|
+
required: false,
|
|
376
|
+
examples: [5, 10, 15]
|
|
377
|
+
})
|
|
378
|
+
},
|
|
379
|
+
// ============================================================================
|
|
380
|
+
// Session binding (#97)
|
|
381
|
+
// ============================================================================
|
|
382
|
+
SPFN_AUTH_BOUND_KEY_TTL_HOURS: {
|
|
383
|
+
...envNumber({
|
|
384
|
+
description: "How long a session key bound to a passkey lives. This is the window in which a copied session cookie is still indistinguishable from the original, so it is hours rather than days; past it the browser runs one WebAuthn ceremony and gets a new key. Only applies to accounts that turned session binding on.",
|
|
385
|
+
default: 24,
|
|
386
|
+
required: false,
|
|
387
|
+
examples: [8, 24, 72]
|
|
388
|
+
})
|
|
389
|
+
},
|
|
390
|
+
SPFN_AUTH_BOUND_KEY_RENEW_GRACE_HOURS: {
|
|
391
|
+
...envNumber({
|
|
392
|
+
description: "How long after a bound key expires a passkey renewal is still offered. Past it the account signs in again. Open-ended grace would make an expired key a long-lived key with extra steps.",
|
|
393
|
+
default: 168,
|
|
394
|
+
required: false,
|
|
395
|
+
examples: [24, 168, 720]
|
|
396
|
+
})
|
|
397
|
+
},
|
|
398
|
+
SPFN_AUTH_CONCURRENT_USE_WINDOW_MS: {
|
|
399
|
+
...envNumber({
|
|
400
|
+
description: "How far apart two sightings of one device key from two client addresses still count as concurrent use, surfaced as `concurrentUseAtMillis` on the key list. A signal for the owner to read, never a refusal \u2014 addresses change legitimately. Meaningful only where proxy-guard is configured, since without it every web request carries the Next.js server's address.",
|
|
401
|
+
default: 3e5,
|
|
402
|
+
required: false,
|
|
403
|
+
examples: [6e4, 3e5, 9e5]
|
|
404
|
+
})
|
|
405
|
+
},
|
|
406
|
+
SPFN_AUTH_SESSION_RENEW_PATH: {
|
|
407
|
+
...envString({
|
|
408
|
+
description: "Page in your app that runs the renewal ceremony. `RequireAuth` redirects a bound session whose key expired here instead of to the sign-in page; the page calls `renewSession(api)` and returns the user to where they were. Override per guard with the `renewalPath` prop.",
|
|
409
|
+
default: "/auth/renew",
|
|
410
|
+
required: false,
|
|
411
|
+
nextjs: true,
|
|
412
|
+
// Read by RequireAuth, which renders in the Next.js runtime
|
|
413
|
+
examples: ["/auth/renew", "/session/renew"]
|
|
414
|
+
})
|
|
415
|
+
},
|
|
363
416
|
// ============================================================================
|
|
364
417
|
// API Configuration
|
|
365
418
|
// ============================================================================
|