Skip to content

Commit

Permalink
Merge pull request #70 from teamhanko/feat/auth-js-v5
Browse files Browse the repository at this point in the history
Auth.js v5 + MFA
  • Loading branch information
merlindru authored May 22, 2024
2 parents e608ccd + ca97297 commit d4b32bb
Show file tree
Hide file tree
Showing 7 changed files with 206 additions and 19 deletions.
Binary file modified packages/js/passkeys-next-auth-provider/bun.lockb
Binary file not shown.
21 changes: 11 additions & 10 deletions packages/js/passkeys-next-auth-provider/client.ts
Original file line number Diff line number Diff line change
@@ -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" };
Expand All @@ -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 {
Expand Down Expand Up @@ -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,
});
}

Expand Down Expand Up @@ -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<JWTPayload> {
export async function apiClientFirstLogin(config: ClientFirstLoginConfig): Promise<JWTPayload> {
const baseUrl = config.baseUrl ?? "https://passkeys.hanko.io";

const loginOptions = await fetch(new URL(`${config.tenantId}/login/initialize`, baseUrl), {
Expand Down
25 changes: 19 additions & 6 deletions packages/js/passkeys-next-auth-provider/index.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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,
Expand All @@ -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()`
Expand All @@ -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");

Expand Down Expand Up @@ -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;
4 changes: 2 additions & 2 deletions packages/js/passkeys-next-auth-provider/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
Binary file modified packages/js/passkeys-sdk/bun.lockb
Binary file not shown.
37 changes: 36 additions & 1 deletion packages/js/passkeys-sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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,
Expand Down
138 changes: 138 additions & 0 deletions packages/js/passkeys-sdk/src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, never>;
Expand Down Expand Up @@ -166,6 +194,8 @@ export interface components {
backup_eligible: boolean;
/** @default false */
backup_state: boolean;
/** @default false */
is_mfa: boolean;
}[];
};
};
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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"];
Expand Down Expand Up @@ -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"];
};
};
}

0 comments on commit d4b32bb

Please sign in to comment.