UNPKG

better-auth

Version:

The most comprehensive authentication library for TypeScript.

978 lines (972 loc) • 32.2 kB
'use strict'; const server = require('@simplewebauthn/server'); const betterCall = require('better-call'); const random = require('../../shared/better-auth.CYeOI8C-.cjs'); const z = require('zod/v4'); const socialProviders_index = require('../../shared/better-auth.Bafolo-S.cjs'); const cookies_index = require('../../cookies/index.cjs'); const schema$1 = require('../../shared/better-auth.BIMq4RPW.cjs'); require('../../shared/better-auth.DiSjtgs9.cjs'); const logger = require('../../shared/better-auth.CXhVNgXP.cjs'); require('defu'); require('@better-auth/utils/hash'); require('@noble/ciphers/chacha'); require('@noble/ciphers/utils'); require('@noble/ciphers/webcrypto'); const base64 = require('@better-auth/utils/base64'); require('jose'); require('@noble/hashes/scrypt'); require('@better-auth/utils'); require('@better-auth/utils/hex'); require('@noble/hashes/utils'); require('@better-auth/utils/random'); require('../../shared/better-auth.C1hdVENX.cjs'); require('../../crypto/index.cjs'); require('@better-fetch/fetch'); require('../../shared/better-auth.C-R0J0n1.cjs'); require('../../shared/better-auth.ANpbi45u.cjs'); require('../../shared/better-auth.D3mtHEZg.cjs'); require('@better-auth/utils/hmac'); require('@better-auth/utils/binary'); require('jose/errors'); function _interopNamespaceCompat(e) { if (e && typeof e === 'object' && 'default' in e) return e; const n = Object.create(null); if (e) { for (const k in e) { n[k] = e[k]; } } n.default = e; return n; } const z__namespace = /*#__PURE__*/_interopNamespaceCompat(z); function getRpID(options, baseURL) { return options.rpID || (baseURL ? new URL(baseURL).hostname : "localhost"); } const passkey = (options) => { const opts = { origin: null, ...options, advanced: { webAuthnChallengeCookie: "better-auth-passkey", ...options?.advanced } }; const expirationTime = new Date(Date.now() + 1e3 * 60 * 5); const currentTime = /* @__PURE__ */ new Date(); const maxAgeInSeconds = Math.floor( (expirationTime.getTime() - currentTime.getTime()) / 1e3 ); const ERROR_CODES = { CHALLENGE_NOT_FOUND: "Challenge not found", YOU_ARE_NOT_ALLOWED_TO_REGISTER_THIS_PASSKEY: "You are not allowed to register this passkey", FAILED_TO_VERIFY_REGISTRATION: "Failed to verify registration", PASSKEY_NOT_FOUND: "Passkey not found", AUTHENTICATION_FAILED: "Authentication failed", UNABLE_TO_CREATE_SESSION: "Unable to create session", FAILED_TO_UPDATE_PASSKEY: "Failed to update passkey" }; return { id: "passkey", endpoints: { generatePasskeyRegistrationOptions: socialProviders_index.createAuthEndpoint( "/passkey/generate-register-options", { method: "GET", use: [socialProviders_index.freshSessionMiddleware], query: z__namespace.object({ authenticatorAttachment: z__namespace.enum(["platform", "cross-platform"]).optional() }).optional(), metadata: { client: false, openapi: { description: "Generate registration options for a new passkey", responses: { 200: { description: "Success", parameters: { query: { authenticatorAttachment: { description: `Type of authenticator to use for registration. "platform" for device-specific authenticators, "cross-platform" for authenticators that can be used across devices.`, required: false } } }, content: { "application/json": { schema: { type: "object", properties: { challenge: { type: "string" }, rp: { type: "object", properties: { name: { type: "string" }, id: { type: "string" } } }, user: { type: "object", properties: { id: { type: "string" }, name: { type: "string" }, displayName: { type: "string" } } }, pubKeyCredParams: { type: "array", items: { type: "object", properties: { type: { type: "string" }, alg: { type: "number" } } } }, timeout: { type: "number" }, excludeCredentials: { type: "array", items: { type: "object", properties: { id: { type: "string" }, type: { type: "string" }, transports: { type: "array", items: { type: "string" } } } } }, authenticatorSelection: { type: "object", properties: { authenticatorAttachment: { type: "string" }, requireResidentKey: { type: "boolean" }, userVerification: { type: "string" } } }, attestation: { type: "string" }, extensions: { type: "object" } } } } } } } } } }, async (ctx) => { const { session } = ctx.context; const userPasskeys = await ctx.context.adapter.findMany({ model: "passkey", where: [ { field: "userId", value: session.user.id } ] }); const userID = new TextEncoder().encode( random.generateRandomString(32, "a-z", "0-9") ); let options2; options2 = await server.generateRegistrationOptions({ rpName: opts.rpName || ctx.context.appName, rpID: getRpID(opts, ctx.context.options.baseURL), userID, userName: session.user.email || session.user.id, userDisplayName: session.user.email || session.user.id, attestationType: "none", excludeCredentials: userPasskeys.map((passkey2) => ({ id: passkey2.credentialID, transports: passkey2.transports?.split( "," ) })), authenticatorSelection: { residentKey: "preferred", userVerification: "preferred", ...opts.authenticatorSelection || {}, ...ctx.query?.authenticatorAttachment ? { authenticatorAttachment: ctx.query.authenticatorAttachment } : {} } }); const id = logger.generateId(32); const webAuthnCookie = ctx.context.createAuthCookie( opts.advanced.webAuthnChallengeCookie ); await ctx.setSignedCookie( webAuthnCookie.name, id, ctx.context.secret, { ...webAuthnCookie.attributes, maxAge: maxAgeInSeconds } ); await ctx.context.internalAdapter.createVerificationValue( { identifier: id, value: JSON.stringify({ expectedChallenge: options2.challenge, userData: { id: session.user.id } }), expiresAt: expirationTime }, ctx ); return ctx.json(options2, { status: 200 }); } ), generatePasskeyAuthenticationOptions: socialProviders_index.createAuthEndpoint( "/passkey/generate-authenticate-options", { method: "POST", body: z__namespace.object({ email: z__namespace.string().meta({ description: "The email address of the user" }).optional() }).optional(), metadata: { openapi: { description: "Generate authentication options for a passkey", responses: { 200: { description: "Success", content: { "application/json": { schema: { type: "object", properties: { challenge: { type: "string" }, rp: { type: "object", properties: { name: { type: "string" }, id: { type: "string" } } }, user: { type: "object", properties: { id: { type: "string" }, name: { type: "string" }, displayName: { type: "string" } } }, timeout: { type: "number" }, allowCredentials: { type: "array", items: { type: "object", properties: { id: { type: "string" }, type: { type: "string" }, transports: { type: "array", items: { type: "string" } } } } }, userVerification: { type: "string" }, authenticatorSelection: { type: "object", properties: { authenticatorAttachment: { type: "string" }, requireResidentKey: { type: "boolean" }, userVerification: { type: "string" } } }, extensions: { type: "object" } } } } } } } } } }, async (ctx) => { const session = await socialProviders_index.getSessionFromCtx(ctx); let userPasskeys = []; if (session) { userPasskeys = await ctx.context.adapter.findMany({ model: "passkey", where: [ { field: "userId", value: session.user.id } ] }); } const options2 = await server.generateAuthenticationOptions({ rpID: getRpID(opts, ctx.context.options.baseURL), userVerification: "preferred", ...userPasskeys.length ? { allowCredentials: userPasskeys.map((passkey2) => ({ id: passkey2.credentialID, transports: passkey2.transports?.split( "," ) })) } : {} }); const data = { expectedChallenge: options2.challenge, userData: { id: session?.user.id || "" } }; const id = logger.generateId(32); const webAuthnCookie = ctx.context.createAuthCookie( opts.advanced.webAuthnChallengeCookie ); await ctx.setSignedCookie( webAuthnCookie.name, id, ctx.context.secret, { ...webAuthnCookie.attributes, maxAge: maxAgeInSeconds } ); await ctx.context.internalAdapter.createVerificationValue( { identifier: id, value: JSON.stringify(data), expiresAt: expirationTime }, ctx ); return ctx.json(options2, { status: 200 }); } ), verifyPasskeyRegistration: socialProviders_index.createAuthEndpoint( "/passkey/verify-registration", { method: "POST", body: z__namespace.object({ response: z__namespace.any(), name: z__namespace.string().meta({ description: "Name of the passkey" }).optional() }), use: [socialProviders_index.freshSessionMiddleware], metadata: { openapi: { description: "Verify registration of a new passkey", responses: { 200: { description: "Success", content: { "application/json": { schema: { $ref: "#/components/schemas/Passkey" } } } }, 400: { description: "Bad request" } } } } }, async (ctx) => { const origin = options?.origin || ctx.headers?.get("origin") || ""; if (!origin) { return ctx.json(null, { status: 400 }); } const resp = ctx.body.response; const webAuthnCookie = ctx.context.createAuthCookie( opts.advanced.webAuthnChallengeCookie ); const challengeId = await ctx.getSignedCookie( webAuthnCookie.name, ctx.context.secret ); if (!challengeId) { throw new betterCall.APIError("BAD_REQUEST", { message: ERROR_CODES.CHALLENGE_NOT_FOUND }); } const data = await ctx.context.internalAdapter.findVerificationValue( challengeId ); if (!data) { return ctx.json(null, { status: 400 }); } const { expectedChallenge, userData } = JSON.parse( data.value ); if (userData.id !== ctx.context.session.user.id) { throw new betterCall.APIError("UNAUTHORIZED", { message: ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_REGISTER_THIS_PASSKEY }); } try { const verification = await server.verifyRegistrationResponse({ response: resp, expectedChallenge, expectedOrigin: origin, expectedRPID: getRpID(opts, ctx.context.options.baseURL), requireUserVerification: false }); const { verified, registrationInfo } = verification; if (!verified || !registrationInfo) { return ctx.json(null, { status: 400 }); } const { aaguid, // credentialID, // credentialPublicKey, // counter, credentialDeviceType, credentialBackedUp, credential, credentialType } = registrationInfo; const pubKey = base64.base64.encode(credential.publicKey); const newPasskey = { name: ctx.body.name, userId: userData.id, credentialID: credential.id, publicKey: pubKey, counter: credential.counter, deviceType: credentialDeviceType, transports: resp.response.transports.join(","), backedUp: credentialBackedUp, createdAt: /* @__PURE__ */ new Date(), aaguid }; const newPasskeyRes = await ctx.context.adapter.create({ model: "passkey", data: newPasskey }); return ctx.json(newPasskeyRes, { status: 200 }); } catch (e) { console.log(e); throw new betterCall.APIError("INTERNAL_SERVER_ERROR", { message: ERROR_CODES.FAILED_TO_VERIFY_REGISTRATION }); } } ), verifyPasskeyAuthentication: socialProviders_index.createAuthEndpoint( "/passkey/verify-authentication", { method: "POST", body: z__namespace.object({ response: z__namespace.record(z__namespace.any(), z__namespace.any()) }), metadata: { openapi: { description: "Verify authentication of a passkey", responses: { 200: { description: "Success", content: { "application/json": { schema: { type: "object", properties: { session: { $ref: "#/components/schemas/Session" }, user: { $ref: "#/components/schemas/User" } } } } } } } }, $Infer: { body: {} } } }, async (ctx) => { const origin = options?.origin || ctx.headers?.get("origin") || ""; if (!origin) { throw new betterCall.APIError("BAD_REQUEST", { message: "origin missing" }); } const resp = ctx.body.response; const webAuthnCookie = ctx.context.createAuthCookie( opts.advanced.webAuthnChallengeCookie ); const challengeId = await ctx.getSignedCookie( webAuthnCookie.name, ctx.context.secret ); if (!challengeId) { throw new betterCall.APIError("BAD_REQUEST", { message: ERROR_CODES.CHALLENGE_NOT_FOUND }); } const data = await ctx.context.internalAdapter.findVerificationValue( challengeId ); if (!data) { throw new betterCall.APIError("BAD_REQUEST", { message: ERROR_CODES.CHALLENGE_NOT_FOUND }); } const { expectedChallenge } = JSON.parse( data.value ); const passkey2 = await ctx.context.adapter.findOne({ model: "passkey", where: [ { field: "credentialID", value: resp.id } ] }); if (!passkey2) { throw new betterCall.APIError("UNAUTHORIZED", { message: ERROR_CODES.PASSKEY_NOT_FOUND }); } try { const verification = await server.verifyAuthenticationResponse({ response: resp, expectedChallenge, expectedOrigin: origin, expectedRPID: getRpID(opts, ctx.context.options.baseURL), credential: { id: passkey2.credentialID, publicKey: base64.base64.decode(passkey2.publicKey), counter: passkey2.counter, transports: passkey2.transports?.split( "," ) }, requireUserVerification: false }); const { verified } = verification; if (!verified) throw new betterCall.APIError("UNAUTHORIZED", { message: ERROR_CODES.AUTHENTICATION_FAILED }); await ctx.context.adapter.update({ model: "passkey", where: [ { field: "id", value: passkey2.id } ], update: { counter: verification.authenticationInfo.newCounter } }); const s = await ctx.context.internalAdapter.createSession( passkey2.userId, ctx ); if (!s) { throw new betterCall.APIError("INTERNAL_SERVER_ERROR", { message: ERROR_CODES.UNABLE_TO_CREATE_SESSION }); } const user = await ctx.context.internalAdapter.findUserById( passkey2.userId ); if (!user) { throw new betterCall.APIError("INTERNAL_SERVER_ERROR", { message: "User not found" }); } await cookies_index.setSessionCookie(ctx, { session: s, user }); return ctx.json( { session: s }, { status: 200 } ); } catch (e) { ctx.context.logger.error("Failed to verify authentication", e); throw new betterCall.APIError("BAD_REQUEST", { message: ERROR_CODES.AUTHENTICATION_FAILED }); } } ), /** * ### Endpoint * * GET `/passkey/list-user-passkeys` * * ### API Methods * * **server:** * `auth.api.listPasskeys` * * **client:** * `authClient.passkey.listUserPasskeys` * * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/passkey#api-method-passkey-list-user-passkeys) */ listPasskeys: socialProviders_index.createAuthEndpoint( "/passkey/list-user-passkeys", { method: "GET", use: [socialProviders_index.sessionMiddleware], metadata: { openapi: { description: "List all passkeys for the authenticated user", responses: { "200": { description: "Passkeys retrieved successfully", content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/Passkey", required: [ "id", "userId", "publicKey", "createdAt", "updatedAt" ] }, description: "Array of passkey objects associated with the user" } } } } } } } }, async (ctx) => { const passkeys = await ctx.context.adapter.findMany({ model: "passkey", where: [{ field: "userId", value: ctx.context.session.user.id }] }); return ctx.json(passkeys, { status: 200 }); } ), /** * ### Endpoint * * POST `/passkey/delete-passkey` * * ### API Methods * * **server:** * `auth.api.deletePasskey` * * **client:** * `authClient.passkey.deletePasskey` * * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/passkey#api-method-passkey-delete-passkey) */ deletePasskey: socialProviders_index.createAuthEndpoint( "/passkey/delete-passkey", { method: "POST", body: z__namespace.object({ id: z__namespace.string().meta({ description: 'The ID of the passkey to delete. Eg: "some-passkey-id"' }) }), use: [socialProviders_index.sessionMiddleware], metadata: { openapi: { description: "Delete a specific passkey", responses: { "200": { description: "Passkey deleted successfully", content: { "application/json": { schema: { type: "object", properties: { status: { type: "boolean", description: "Indicates whether the deletion was successful" } }, required: ["status"] } } } } } } } }, async (ctx) => { await ctx.context.adapter.delete({ model: "passkey", where: [ { field: "id", value: ctx.body.id } ] }); return ctx.json(null, { status: 200 }); } ), /** * ### Endpoint * * POST `/passkey/update-passkey` * * ### API Methods * * **server:** * `auth.api.updatePasskey` * * **client:** * `authClient.passkey.updatePasskey` * * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/passkey#api-method-passkey-update-passkey) */ updatePasskey: socialProviders_index.createAuthEndpoint( "/passkey/update-passkey", { method: "POST", body: z__namespace.object({ id: z__namespace.string().meta({ description: `The ID of the passkey which will be updated. Eg: "passkey-id"` }), name: z__namespace.string().meta({ description: `The new name which the passkey will be updated to. Eg: "my-new-passkey-name"` }) }), use: [socialProviders_index.sessionMiddleware], metadata: { openapi: { description: "Update a specific passkey's name", responses: { "200": { description: "Passkey updated successfully", content: { "application/json": { schema: { type: "object", properties: { passkey: { $ref: "#/components/schemas/Passkey" } }, required: ["passkey"] } } } } } } } }, async (ctx) => { const passkey2 = await ctx.context.adapter.findOne({ model: "passkey", where: [ { field: "id", value: ctx.body.id } ] }); if (!passkey2) { throw new betterCall.APIError("NOT_FOUND", { message: ERROR_CODES.PASSKEY_NOT_FOUND }); } if (passkey2.userId !== ctx.context.session.user.id) { throw new betterCall.APIError("UNAUTHORIZED", { message: ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_REGISTER_THIS_PASSKEY }); } const updatedPasskey = await ctx.context.adapter.update({ model: "passkey", where: [ { field: "id", value: ctx.body.id } ], update: { name: ctx.body.name } }); if (!updatedPasskey) { throw new betterCall.APIError("INTERNAL_SERVER_ERROR", { message: ERROR_CODES.FAILED_TO_UPDATE_PASSKEY }); } return ctx.json( { passkey: updatedPasskey }, { status: 200 } ); } ) }, schema: schema$1.mergeSchema(schema, options?.schema), $ERROR_CODES: ERROR_CODES }; }; const schema = { passkey: { fields: { name: { type: "string", required: false }, publicKey: { type: "string", required: true }, userId: { type: "string", references: { model: "user", field: "id" }, required: true }, credentialID: { type: "string", required: true }, counter: { type: "number", required: true }, deviceType: { type: "string", required: true }, backedUp: { type: "boolean", required: true }, transports: { type: "string", required: false }, createdAt: { type: "date", required: false }, aaguid: { type: "string", required: false } } } }; exports.passkey = passkey;