diff --git a/packages/js/passkeys-next-auth-provider/bun.lockb b/packages/js/passkeys-next-auth-provider/bun.lockb index a402e54..22a3d1e 100755 Binary files a/packages/js/passkeys-next-auth-provider/bun.lockb and b/packages/js/passkeys-next-auth-provider/bun.lockb differ diff --git a/packages/js/passkeys-next-auth-provider/client.ts b/packages/js/passkeys-next-auth-provider/client.ts index e1e15c7..e992219 100644 --- a/packages/js/passkeys-next-auth-provider/client.ts +++ b/packages/js/passkeys-next-auth-provider/client.ts @@ -1,6 +1,6 @@ -import { type CredentialRequestOptionsJSON, get } from "@github/webauthn-json"; +import { get, type CredentialRequestOptionsJSON } from "@github/webauthn-json"; import { type JWTPayload } from "jose"; -import { signIn } from "next-auth/react"; +import { SignInOptions, signIn } from "next-auth/react"; import { DEFAULT_PROVIDER_ID } from "."; const headers = { "Content-Type": "application/json" }; @@ -10,13 +10,11 @@ interface Common { signal?: AbortSignal; } -interface SignInConfig extends Common { +interface SignInConfig extends Common, SignInOptions { tenantId: string; baseUrl?: string; provider?: string; - callbackUrl?: string; - redirect?: boolean; } interface ClientFirstLoginConfig extends Common { @@ -46,12 +44,11 @@ export async function signInWithPasskey(config: SignInConfig) { config.signal = controller.signal; } - const finalizeJWT = await clientFirstPasskeyLogin(config); + const finalizeJWT = await apiClientFirstLogin(config); await signIn(config.provider ?? DEFAULT_PROVIDER_ID, { finalizeJWT, - callbackUrl: config.callbackUrl, - redirect: config.redirect, + ...config, }); } @@ -80,12 +77,16 @@ signInWithPasskey.conditional = function (config: SignInConfig) { * * This method runs the ["Client-First Login Flow"]() triggers the "select passkey" dialog and returns a JWT signed by the Hanko Passkey API. * - * It can then be used to sign in e.g. with the PasskeyProvider, passing the returned JWT as the `finalizeJWT` parameter. + * It does NOT sign in the user on the backend or interact with NextAuth/Auth.js in any way. + * + * The JWT this function returns can then be used to sign in e.g. with the `Passkeys` provider, passing the returned JWT as the `finalizeJWT` parameter. + * + * It includes the user ID and username, signed by the Hanko Passkey API. (`{tenantId}/.well-known/jwks.json`) * * @returns a JWT that can be exchanged for a session on the backend. * To verify the JWT, use the JWKS endpoint of the tenant. (`{tenantId}/.well-known/jwks.json`) */ -export async function clientFirstPasskeyLogin(config: ClientFirstLoginConfig): Promise { +export async function apiClientFirstLogin(config: ClientFirstLoginConfig): Promise { const baseUrl = config.baseUrl ?? "https://passkeys.hanko.io"; const loginOptions = await fetch(new URL(`${config.tenantId}/login/initialize`, baseUrl), { diff --git a/packages/js/passkeys-next-auth-provider/index.ts b/packages/js/passkeys-next-auth-provider/index.ts index 4c0d387..bb75ebc 100644 --- a/packages/js/passkeys-next-auth-provider/index.ts +++ b/packages/js/passkeys-next-auth-provider/index.ts @@ -1,6 +1,7 @@ import { Tenant } from "@teamhanko/passkeys-sdk"; import { JWTPayload, JWTVerifyResult, createRemoteJWKSet, jwtVerify } from "jose"; -import CredentialsProvider from "next-auth/providers/credentials"; +import Credentials, { CredentialsConfig } from "next-auth/providers/credentials"; +import { Provider } from "next-auth/providers/index"; export * from "@teamhanko/passkeys-sdk"; @@ -19,7 +20,9 @@ export enum ErrorCode { JWTExpired = "jwtExpired", } -export function PasskeyProvider({ +// TODO in future versions, remove `export const PasskeyProvider/Passkeys` and make this `export default function Passkeys` +// this is only named this way so we can export both the legacy (PasskeyProvider) and new (Passkeys) variable names +function createPasskeyProvider({ tenant, authorize: authorize, id = DEFAULT_PROVIDER_ID, @@ -46,8 +49,10 @@ export function PasskeyProvider({ const JWKS = createRemoteJWKSet(url); // TODO call normally when this is fixed: https://github.com/nextauthjs/next-auth/issues/572 - return ((CredentialsProvider as any).default as typeof CredentialsProvider)({ + return { id, + name: "Passkeys", + type: "credentials", credentials: { /** * Token returned by `passkeyApi.login.finalize()` @@ -56,8 +61,8 @@ export function PasskeyProvider({ label: "JWT returned by /login/finalize", type: "text", }, - }, - async authorize(credentials) { + } as any, + async authorize(credentials?: { finalizeJWT?: string }) { const jwt = credentials?.finalizeJWT; if (!jwt) throw new Error("No JWT provided"); @@ -89,5 +94,13 @@ export function PasskeyProvider({ return user; }, - }); + } as const; } + +/** + * @deprecated Use the default export instead + */ +export const PasskeyProvider = createPasskeyProvider; + +const Passkeys = createPasskeyProvider; // So TS language server auto-imports it as "Passkeys" which is the preferred spelling in Auth.js v5 +export default Passkeys; diff --git a/packages/js/passkeys-next-auth-provider/package.json b/packages/js/passkeys-next-auth-provider/package.json index 336d3a6..3be9f0e 100644 --- a/packages/js/passkeys-next-auth-provider/package.json +++ b/packages/js/passkeys-next-auth-provider/package.json @@ -7,11 +7,11 @@ "typescript": "^5.0.0" }, "dependencies": { - "@teamhanko/passkeys-sdk": "^0.1.8", "@github/webauthn-json": "^2.1.1", + "@teamhanko/passkeys-sdk": "^0.1.8", "jose": "^5.1.1" }, "peerDependencies": { - "next-auth": "^4.24.5" + "next-auth": "^5.0.0-beta.17" } } diff --git a/packages/js/passkeys-sdk/bun.lockb b/packages/js/passkeys-sdk/bun.lockb index 90dfd6f..651ad48 100755 Binary files a/packages/js/passkeys-sdk/bun.lockb and b/packages/js/passkeys-sdk/bun.lockb differ diff --git a/packages/js/passkeys-sdk/src/index.ts b/packages/js/passkeys-sdk/src/index.ts index ba9aec7..e80e403 100644 --- a/packages/js/passkeys-sdk/src/index.ts +++ b/packages/js/passkeys-sdk/src/index.ts @@ -76,6 +76,41 @@ export function tenant(config: { baseUrl?: string; apiKey: string; tenantId: str }) ); }, + mfa: { + registration: { + initialize(data: { userId: string; username: string; icon?: string; displayName?: string }) { + return wrap( + client.POST("/{tenant_id}/mfa/registration/initialize", { + params, + body: { + user_id: data.userId, + username: data.username, + icon: data.icon, + display_name: data.displayName, + }, + }) + ); + }, + finalize(credential: PostRegistrationFinalizeBody) { + return wrap( + client.POST("/{tenant_id}/mfa/registration/finalize", { params, body: credential }) + ); + }, + }, + login: { + initialize(data: { userId: string }) { + return wrap( + client.POST("/{tenant_id}/mfa/login/initialize", { + params, + body: { user_id: data.userId }, + }) + ); + }, + finalize(credential: PostLoginFinalizeBody) { + return wrap(client.POST("/{tenant_id}/mfa/login/finalize", { params, body: credential })); + }, + }, + }, }; }, jwks() { @@ -93,7 +128,7 @@ export function tenant(config: { baseUrl?: string; apiKey: string; tenantId: str }, }, registration: { - initialize(data: { userId: string; username: string; icon?: string | null; displayName?: string | null }) { + initialize(data: { userId: string; username: string; icon?: string; displayName?: string }) { return wrap( client.POST("/{tenant_id}/registration/initialize", { params, diff --git a/packages/js/passkeys-sdk/src/schema.ts b/packages/js/passkeys-sdk/src/schema.ts index b240523..2770c75 100644 --- a/packages/js/passkeys-sdk/src/schema.ts +++ b/packages/js/passkeys-sdk/src/schema.ts @@ -73,6 +73,34 @@ export interface paths { */ post: operations["post-tenant_id-transaction-finalize"]; }; + "/{tenant_id}/mfa/registration/initialize": { + /** + * Start MFA Registration + * @description Initialize a registration for mfa credentials + */ + post: operations["post-mfa-registration-initialize"]; + }; + "/{tenant_id}/mfa/registration/finalize": { + /** + * Finish MFA Registration + * @description Finish credential registration process + */ + post: operations["post-mfa-registration-finalize"]; + }; + "/{tenant_id}/mfa/login/initialize": { + /** + * Start MFA Login + * @description Initialize a login flow for MFA + */ + post: operations["post-mfa-login-initialize"]; + }; + "/{tenant_id}/mfa/login/finalize": { + /** + * Finish MFA Login + * @description Finalize the login operation + */ + post: operations["post-mfa-login-finalize"]; + }; } export type webhooks = Record; @@ -166,6 +194,8 @@ export interface components { backup_eligible: boolean; /** @default false */ backup_state: boolean; + /** @default false */ + is_mfa: boolean; }[]; }; }; @@ -345,6 +375,22 @@ export interface components { }; }; }; + /** @description Body for login/initialize */ + "post-login-initialize"?: { + content: { + "application/json": { + /** @description optional - when provided the API Key needs to be sent to the server too. */ + user_id?: string; + }; + }; + }; + "post-mfa-login-initialize"?: { + content: { + "application/json": { + user_id: string; + }; + }; + }; }; headers: never; pathItems: never; @@ -485,6 +531,7 @@ export interface operations { tenant_id: components["parameters"]["tenant_id"]; }; }; + requestBody: components["requestBodies"]["post-login-initialize"]; responses: { 200: components["responses"]["post-login-initialize"]; 400: components["responses"]["error"]; @@ -570,4 +617,95 @@ export interface operations { 500: components["responses"]["error"]; }; }; + /** + * Start MFA Registration + * @description Initialize a registration for mfa credentials + */ + "post-mfa-registration-initialize": { + parameters: { + header: { + apiKey: components["parameters"]["X-API-KEY"]; + }; + path: { + /** @description Tenant ID */ + tenant_id: string; + }; + }; + requestBody: components["requestBodies"]["post-registration-initialize"]; + responses: { + 200: components["responses"]["post-registration-initialize"]; + 400: components["responses"]["error"]; + 401: components["responses"]["error"]; + 500: components["responses"]["error"]; + }; + }; + /** + * Finish MFA Registration + * @description Finish credential registration process + */ + "post-mfa-registration-finalize": { + parameters: { + header: { + apiKey: components["parameters"]["X-API-KEY"]; + }; + path: { + /** @description Tenant ID */ + tenant_id: string; + }; + }; + requestBody: components["requestBodies"]["post-registration-finalize"]; + responses: { + 200: components["responses"]["token"]; + 400: components["responses"]["error"]; + 401: components["responses"]["error"]; + 404: components["responses"]["error"]; + 500: components["responses"]["error"]; + }; + }; + /** + * Start MFA Login + * @description Initialize a login flow for MFA + */ + "post-mfa-login-initialize": { + parameters: { + header: { + apiKey: components["parameters"]["X-API-KEY"]; + }; + path: { + /** @description Tenant ID */ + tenant_id: string; + }; + }; + requestBody: components["requestBodies"]["post-mfa-login-initialize"]; + responses: { + 200: components["responses"]["post-login-initialize"]; + 400: components["responses"]["error"]; + 401: components["responses"]["error"]; + 404: components["responses"]["error"]; + 500: components["responses"]["error"]; + }; + }; + /** + * Finish MFA Login + * @description Finalize the login operation + */ + "post-mfa-login-finalize": { + parameters: { + header: { + apiKey: components["parameters"]["X-API-KEY"]; + }; + path: { + /** @description Tenant ID */ + tenant_id: string; + }; + }; + requestBody: components["requestBodies"]["post-login-finalize"]; + responses: { + 200: components["responses"]["token"]; + 400: components["responses"]["error"]; + 401: components["responses"]["error"]; + 404: components["responses"]["error"]; + 500: components["responses"]["error"]; + }; + }; }