Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/core/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@seamless-auth/core",
"version": "0.4.6",
"version": "0.5.0",
"description": "Framework-agnostic core authentication logic for SeamlessAuth",
"license": "AGPL-3.0-only",
"author": "Fells Code, LLC",
Expand Down
16 changes: 16 additions & 0 deletions packages/core/src/ensureCookies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,22 @@ const COOKIE_REQUIREMENTS: Record<
name: "registrationCookieName",
required: true,
},
"/otp/verify-login-email-otp": {
name: "preAuthCookieName",
required: true,
},
"/otp/verify-login-phone-otp": {
name: "preAuthCookieName",
required: true,
},
"/otp/generate-login-email-otp": {
name: "preAuthCookieName",
required: true,
},
"/otp/generate-login-phone-otp": {
name: "preAuthCookieName",
required: true,
},
"/magic-link": {
name: "preAuthCookieName",
required: true,
Expand Down
24 changes: 22 additions & 2 deletions packages/core/src/handlers/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,12 @@ export interface LoginOptions {

export interface LoginResult {
status: number;
error?: string;
body?: {
message?: string;
identifierType?: string;
loginMethods?: string[];
};
error?: unknown;
setCookies?: {
name: string;
value: CookiePayload;
Expand Down Expand Up @@ -56,8 +61,23 @@ export async function loginHandler(
throw new Error("Signature mismatch with data payload");
}

const body = {
...(typeof data.message === "string" ? { message: data.message } : {}),
...(typeof data.identifierType === "string"
? { identifierType: data.identifierType }
: {}),
...(Array.isArray(data.loginMethods)
? {
loginMethods: data.loginMethods.filter(
(item: unknown) => typeof item === "string",
),
}
: {}),
};

return {
status: 204,
status: up.status,
body,
setCookies: [
{
name: opts.preAuthCookieName,
Expand Down
12 changes: 9 additions & 3 deletions packages/core/src/handlers/requestOtpHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { authFetch } from "../authFetch.js";

export interface RequestOtpInput {
authorization?: string;
flow?: "registration" | "login";
kind: "email" | "phone";
}

Expand All @@ -21,10 +22,15 @@ export async function requestOtpHandler(
input: RequestOtpInput,
opts: RequestOtpOptions,
): Promise<RequestOtpResult> {
const flow = input.flow ?? "registration";
const path =
input.kind === "email"
? "otp/generate-email-otp"
: "otp/generate-phone-otp";
flow === "login"
? input.kind === "email"
? "otp/generate-login-email-otp"
: "otp/generate-login-phone-otp"
: input.kind === "email"
? "otp/generate-email-otp"
: "otp/generate-phone-otp";

const up = await authFetch(`${opts.authServerUrl}/${path}`, {
method: "GET",
Expand Down
101 changes: 101 additions & 0 deletions packages/core/src/handlers/verifyLoginOtpHandler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { authFetch } from "../authFetch.js";
import type { CookiePayload } from "../ensureCookies.js";
import { verifySignedAuthResponse } from "../verifySignedAuthResponse.js";

export interface VerifyLoginOtpInput {
body: unknown;
authorization?: string;
forwardedClientIp?: string;
kind: "email" | "phone";
}

export interface VerifyLoginOtpOptions {
authServerUrl: string;
cookieDomain?: string;
accessCookieName: string;
refreshCookieName: string;
}

export interface VerifyLoginOtpResult {
status: number;
body?: unknown;
error?: unknown;
setCookies?: {
name: string;
value: CookiePayload;
ttl: number;
domain?: string;
}[];
}

export async function verifyLoginOtpHandler(
input: VerifyLoginOtpInput,
opts: VerifyLoginOtpOptions,
): Promise<VerifyLoginOtpResult> {
const path =
input.kind === "email"
? "otp/verify-login-email-otp"
: "otp/verify-login-phone-otp";

const up = await authFetch(`${opts.authServerUrl}/${path}`, {
method: "POST",
body: input.body,
authorization: input.authorization,
forwardedClientIp: input.forwardedClientIp,
});

const data = await up.json();

if (!up.ok) {
return {
status: up.status,
error: data,
};
}

const verifiedAccessToken = await verifySignedAuthResponse(
data.token,
opts.authServerUrl,
);

if (!verifiedAccessToken) {
throw new Error("Invalid signed response from Auth Server");
}

if (verifiedAccessToken.sub !== data.sub) {
throw new Error("Signature mismatch with data payload");
}

const sessionId =
typeof verifiedAccessToken.sid === "string"
? verifiedAccessToken.sid
: undefined;

return {
status: up.status,
body: data,
setCookies: [
{
name: opts.accessCookieName,
value: {
sub: data.sub,
...(sessionId === undefined ? {} : { sessionId }),
roles: data.roles,
email: data.email,
phone: data.phone,
},
ttl: data.ttl,
domain: opts.cookieDomain,
},
{
name: opts.refreshCookieName,
value: {
sub: data.sub,
refreshToken: data.refreshToken,
},
ttl: data.refreshTtl,
domain: opts.cookieDomain,
},
],
};
}
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export * from "./handlers/finishRegister.js";
export * from "./handlers/logout.js";
export * from "./handlers/me.js";
export * from "./handlers/requestOtpHandler.js";
export * from "./handlers/verifyLoginOtpHandler.js";
export * from "./handlers/verifyMagicLinkHandler.js";
export * from "./handlers/requestMagicLinkHandler.js";
export * from "./handlers/pollMagicLinkConfirmationHandler.js";
23 changes: 23 additions & 0 deletions packages/core/tests/ensureCookes.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,29 @@ describe("ensureCookies", () => {
});
});

it("requires the pre-auth cookie for login OTP routes", async () => {
const { ensureCookies } = await import("../dist/ensureCookies.js");

verifyCookieJwtMock.mockReturnValue({
sub: "user-123",
roles: ["user"],
});

const result = await ensureCookies(
{
path: "/otp/generate-login-email-otp",
cookies: { preauth: "valid.preauth.jwt" },
},
BASE_OPTS,
);

expect(result.type).toBe("ok");
expect(result.user).toEqual({
sub: "user-123",
roles: ["user"],
});
});

it("requires the access cookie for step-up routes", async () => {
const { ensureCookies } = await import("../dist/ensureCookies.js");

Expand Down
83 changes: 83 additions & 0 deletions packages/core/tests/loginHandler.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { jest } from "@jest/globals";
import { exportJWK, generateKeyPair, SignJWT } from "jose";

function jsonResponse(status, body) {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}

async function createSignedAuthResponse(subject = "user-123") {
const { privateKey, publicKey } = await generateKeyPair("RS256");
const jwk = await exportJWK(publicKey);
jwk.alg = "RS256";
jwk.kid = "test-key";
jwk.use = "sig";

const token = await new SignJWT({ sub: subject })
.setProtectedHeader({ alg: "RS256", kid: "test-key" })
.setIssuer("https://auth.example.com")
.setSubject(subject)
.setExpirationTime("5m")
.sign(privateKey);

return { token, jwk };
}

describe("loginHandler", () => {
const originalFetch = global.fetch;

afterEach(() => {
global.fetch = originalFetch;
});

it("returns sanitized login policy metadata and stores the pre-auth token in cookies", async () => {
const { token, jwk } = await createSignedAuthResponse();

global.fetch = jest.fn(async (url) => {
if (url === "https://auth.example.com/login") {
return jsonResponse(200, {
message: "Success",
identifierType: "email",
loginMethods: ["passkey", "magic_link", "email_otp"],
sub: "user-123",
token,
ttl: 300,
});
}

if (url === "https://auth.example.com/.well-known/jwks.json") {
return jsonResponse(200, { keys: [jwk] });
}

throw new Error(`Unexpected fetch URL: ${url}`);
});

const { loginHandler } = await import("../dist/handlers/login.js");

const result = await loginHandler(
{ body: { identifier: "user@example.com", passkeyAvailable: true } },
{
authServerUrl: "https://auth.example.com",
preAuthCookieName: "preauth",
},
);

expect(result.status).toBe(200);
expect(result.body).toEqual({
message: "Success",
identifierType: "email",
loginMethods: ["passkey", "magic_link", "email_otp"],
});
expect(result.body).not.toHaveProperty("token");
expect(result.setCookies).toEqual([
{
name: "preauth",
value: { sub: "user-123", token },
ttl: 300,
domain: undefined,
},
]);
});
});
58 changes: 58 additions & 0 deletions packages/core/tests/requestOtpHandler.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { jest } from "@jest/globals";

function jsonResponse(status, body) {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}

describe("requestOtpHandler", () => {
const originalFetch = global.fetch;

beforeEach(() => {
global.fetch = jest
.fn()
.mockResolvedValue(jsonResponse(200, { message: "sent" }));
});

afterEach(() => {
global.fetch = originalFetch;
});

it("uses registration OTP endpoints by default", async () => {
const { requestOtpHandler } = await import(
"../dist/handlers/requestOtpHandler.js"
);

await requestOtpHandler(
{ kind: "email", authorization: "Bearer service-token" },
{ authServerUrl: "https://auth.example.com" },
);

expect(global.fetch).toHaveBeenCalledWith(
"https://auth.example.com/otp/generate-email-otp",
expect.objectContaining({ method: "GET" }),
);
});

it("uses login OTP endpoints when requested", async () => {
const { requestOtpHandler } = await import(
"../dist/handlers/requestOtpHandler.js"
);

await requestOtpHandler(
{
kind: "phone",
flow: "login",
authorization: "Bearer service-token",
},
{ authServerUrl: "https://auth.example.com" },
);

expect(global.fetch).toHaveBeenCalledWith(
"https://auth.example.com/otp/generate-login-phone-otp",
expect.objectContaining({ method: "GET" }),
);
});
});
Loading
Loading