mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
test(auth): cover the native OIDC SSO handoff
Unit-test the handoff library (mint stores only the hash + binds the challenge + 90s TTL; consume covers not-found / replay-with-revoke / expiry-burn / PKCE-burn / atomic consume / lost-race), the token-exchange route (rate limit, transport gate, one identical generic 401 across every invalid-code class, success with issued-hash stamp + audit, and OIDC_ONLY does not block it), the callback native branches (handoff mint + scheme redirect with no session, MFA ticket on the scheme with no session/cookie, post-identity + IdP errors to the scheme, CSRF web-fallback), the login native-param handling, and the by-hash refresh-token revoke used for replay containment.
This commit is contained in:
1 parent
e6d9cc229a
commit
ada147522f
5 files changed
+692
No files matched your search
@@ -68,6 +68,18 @@ vi.mock("@/lib/auth/oidc", () => ({
|
||||
deriveUniqueUsername: vi.fn(async (email: string) => email.split("@")[0]),
|
||||
}));
|
||||
|
||||
// The handoff lib is unit-tested separately; here we assert the callback
|
||||
// branches into it and redirects to the custom scheme (real
|
||||
// `buildNativeCallbackUrl`, mocked `mintNativeHandoff`).
|
||||
vi.mock("@/lib/auth/oidc-native-handoff", () => ({
|
||||
buildNativeCallbackUrl: (params: Record<string, string>) =>
|
||||
`healthlog://oidc-callback?${new URLSearchParams(params).toString()}`,
|
||||
mintNativeHandoff: vi.fn(async () => ({
|
||||
code: "hlh_test_code",
|
||||
handoffId: "ho-1",
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
|
||||
import { prisma } from "@/lib/db";
|
||||
@@ -80,6 +92,7 @@ import {
|
||||
exchangeCodeForTokens,
|
||||
verifyIdToken,
|
||||
} from "@/lib/auth/oidc";
|
||||
import { mintNativeHandoff } from "@/lib/auth/oidc-native-handoff";
|
||||
|
||||
const CONFIG = {
|
||||
issuerUrl: "https://idp.example.com",
|
||||
@@ -577,3 +590,127 @@ describe("GET /api/auth/oidc/callback", () => {
|
||||
expect(successCookie.toLowerCase()).toContain("path=/api/auth/oidc");
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/auth/oidc/callback — native branch", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 20,
|
||||
reset: 0,
|
||||
ip: "1.2.3.4",
|
||||
} as never);
|
||||
vi.mocked(getOidcConfig).mockReturnValue(CONFIG);
|
||||
vi.mocked(discoverOidcMetadata).mockResolvedValue(METADATA);
|
||||
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0);
|
||||
vi.mocked(mintNativeHandoff).mockResolvedValue({
|
||||
code: "hlh_test_code",
|
||||
handoffId: "ho-1",
|
||||
});
|
||||
});
|
||||
|
||||
function nativeStateCookie(overrides: Record<string, unknown> = {}): string {
|
||||
return stateCookie({
|
||||
state: "STATE",
|
||||
nonce: "nonce-1",
|
||||
codeVerifier: "verifier-1",
|
||||
next: "/",
|
||||
native: true,
|
||||
appCodeChallenge: "app-challenge-123",
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
function nativeRequest(): NextRequest {
|
||||
return makeRequest({
|
||||
query: "?code=abc&state=STATE",
|
||||
cookie: nativeStateCookie(),
|
||||
});
|
||||
}
|
||||
|
||||
it("mints a handoff code and 302s to the custom scheme — no session", async () => {
|
||||
mockIdentity();
|
||||
mockUserLookups({
|
||||
byIdentity: userRow({ oidcIssuer: METADATA.issuer, oidcSub: "sub-1" }),
|
||||
});
|
||||
|
||||
const res = await GET(nativeRequest());
|
||||
const location = res.headers.get("location")!;
|
||||
expect(location.startsWith("healthlog://oidc-callback?code=")).toBe(true);
|
||||
expect(location).toContain("code=hlh_test_code");
|
||||
// The app's challenge (from the tamper-authenticated blob) is bound at mint.
|
||||
expect(mintNativeHandoff).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: "user-1",
|
||||
appCodeChallenge: "app-challenge-123",
|
||||
}),
|
||||
);
|
||||
// No web session is minted on the native branch.
|
||||
expect(createSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("hands the MFA ticket to the custom scheme (no session, no MFA cookie)", async () => {
|
||||
mockIdentity();
|
||||
mockUserLookups({
|
||||
byIdentity: userRow({
|
||||
oidcIssuer: METADATA.issuer,
|
||||
oidcSub: "sub-1",
|
||||
totpConfirmedAt: new Date(),
|
||||
}),
|
||||
});
|
||||
|
||||
const res = await GET(nativeRequest());
|
||||
const location = res.headers.get("location")!;
|
||||
expect(location.startsWith("healthlog://oidc-callback?")).toBe(true);
|
||||
expect(location).toContain("mfa_ticket=mfa-ticket-1");
|
||||
expect(location).toContain("methods=totp%2Crecovery");
|
||||
// No handoff code on the MFA branch, and no session.
|
||||
expect(location).not.toContain("code=hlh_");
|
||||
expect(mintNativeHandoff).not.toHaveBeenCalled();
|
||||
expect(createSession).not.toHaveBeenCalled();
|
||||
// The web-only MFA handoff cookie is never set on the native branch.
|
||||
const mfaCookie = res.headers
|
||||
.getSetCookie()
|
||||
.find((c) => c.startsWith("oidc_mfa_handoff="));
|
||||
expect(mfaCookie).toBeUndefined();
|
||||
});
|
||||
|
||||
it("delivers a post-identity error to the custom scheme, not a web page", async () => {
|
||||
mockIdentity();
|
||||
// An account already pinned to a different IdP identity → identity_conflict.
|
||||
mockUserLookups({
|
||||
byIdentity: null,
|
||||
byEmail: userRow({ oidcIssuer: "https://other.example", oidcSub: "x" }),
|
||||
});
|
||||
|
||||
const res = await GET(nativeRequest());
|
||||
const location = res.headers.get("location")!;
|
||||
expect(location).toBe(
|
||||
"healthlog://oidc-callback?error=oidc_identity_conflict",
|
||||
);
|
||||
});
|
||||
|
||||
it("delivers an IdP denial to the custom scheme", async () => {
|
||||
const res = await GET(
|
||||
makeRequest({
|
||||
query: "?error=access_denied&state=STATE",
|
||||
cookie: nativeStateCookie(),
|
||||
}),
|
||||
);
|
||||
expect(res.headers.get("location")).toBe(
|
||||
"healthlog://oidc-callback?error=oidc_denied",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the WEB oidc_state page on a CSRF mismatch (broken flow)", async () => {
|
||||
const res = await GET(
|
||||
makeRequest({
|
||||
query: "?code=abc&state=WRONG",
|
||||
cookie: nativeStateCookie(),
|
||||
}),
|
||||
);
|
||||
// A tampered/broken round-trip never hands the app a scheme redirect.
|
||||
expect(res.headers.get("location")).toContain(
|
||||
"/auth/login?error=oidc_state",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -150,4 +150,67 @@ describe("GET /api/auth/oidc/login", () => {
|
||||
const res = await GET(makeRequest());
|
||||
expect(res.headers.get("location")).toContain("error=oidc_failed");
|
||||
});
|
||||
|
||||
describe("native SSO start", () => {
|
||||
const CONFIG = {
|
||||
issuerUrl: "https://idp.example.com",
|
||||
clientId: "client-1",
|
||||
clientSecret: "secret-1",
|
||||
scopes: "openid email profile",
|
||||
buttonLabel: "SSO",
|
||||
};
|
||||
const CHALLENGE = "a".repeat(43); // valid S256 length
|
||||
|
||||
function storedFromCookie(res: Awaited<ReturnType<typeof GET>>) {
|
||||
const cookie = res.cookies.get("oidc_auth_state");
|
||||
return JSON.parse(cookie!.value.replace(/^enc\(/, "").replace(/\)$/, ""));
|
||||
}
|
||||
|
||||
it("rejects a native start with a missing challenge to the custom scheme", async () => {
|
||||
vi.mocked(getOidcConfig).mockReturnValue(CONFIG);
|
||||
const res = await GET(makeRequest("/api/auth/oidc/login?client=native"));
|
||||
expect(res.headers.get("location")).toBe(
|
||||
"healthlog://oidc-callback?error=oidc_invalid_request",
|
||||
);
|
||||
});
|
||||
|
||||
it("routes native login errors (disabled) to the custom scheme", async () => {
|
||||
vi.mocked(getOidcConfig).mockReturnValue(null);
|
||||
const res = await GET(
|
||||
makeRequest(
|
||||
`/api/auth/oidc/login?client=native&code_challenge=${CHALLENGE}`,
|
||||
),
|
||||
);
|
||||
expect(res.headers.get("location")).toBe(
|
||||
"healthlog://oidc-callback?error=oidc_disabled",
|
||||
);
|
||||
});
|
||||
|
||||
it("folds native:true + appCodeChallenge into the state blob and pins next to /", async () => {
|
||||
vi.mocked(getOidcConfig).mockReturnValue(CONFIG);
|
||||
vi.mocked(discoverOidcMetadata).mockResolvedValue({
|
||||
issuer: "https://idp.example.com",
|
||||
authorization_endpoint: "https://idp.example.com/authorize",
|
||||
token_endpoint: "https://idp.example.com/token",
|
||||
jwks_uri: "https://idp.example.com/jwks",
|
||||
});
|
||||
|
||||
const res = await GET(
|
||||
makeRequest(
|
||||
`/api/auth/oidc/login?client=native&code_challenge=${CHALLENGE}&next=/dashboard`,
|
||||
),
|
||||
);
|
||||
// Still a normal redirect to the IdP — the native fact rides the blob.
|
||||
expect(res.headers.get("location")).toBe(
|
||||
"https://idp.example.com/authorize?x=1",
|
||||
);
|
||||
const stored = storedFromCookie(res);
|
||||
expect(stored.native).toBe(true);
|
||||
expect(stored.appCodeChallenge).toBe(CHALLENGE);
|
||||
// `next` is meaningless to the app — pinned to "/".
|
||||
expect(stored.next).toBe("/");
|
||||
// The server↔IdP verifier stays independent and present.
|
||||
expect(typeof stored.codeVerifier).toBe("string");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,225 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: (fn: unknown) => fn,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { user: { findUnique: vi.fn() } },
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkAuthSurfaceRateLimit: vi.fn(),
|
||||
rateLimitHeaders: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/native-client", () => ({
|
||||
isCookielessNativeCaller: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/oidc-native-handoff", () => ({
|
||||
consumeNativeHandoff: vi.fn(),
|
||||
stampIssuedRefreshToken: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/login-response", () => ({
|
||||
finishLogin: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
|
||||
import { isCookielessNativeCaller } from "@/lib/auth/native-client";
|
||||
import {
|
||||
consumeNativeHandoff,
|
||||
stampIssuedRefreshToken,
|
||||
} from "@/lib/auth/oidc-native-handoff";
|
||||
import { finishLogin } from "@/lib/auth/login-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
|
||||
const VALID_BODY = {
|
||||
code: "hlh_" + "a".repeat(43),
|
||||
codeVerifier: "a".repeat(43),
|
||||
};
|
||||
|
||||
function makeRequest(
|
||||
body: unknown = VALID_BODY,
|
||||
headers: Record<string, string> = {},
|
||||
): NextRequest {
|
||||
return new NextRequest("http://localhost/api/auth/oidc/native/token", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", ...headers },
|
||||
body: typeof body === "string" ? body : JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
function nativeBundleResponse() {
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
user: { id: "user-1", username: "alice" },
|
||||
token: "hlk_access",
|
||||
tokenExpiresAt: new Date().toISOString(),
|
||||
refreshToken: "hlr_refresh",
|
||||
refreshTokenExpiresAt: new Date().toISOString(),
|
||||
},
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 10,
|
||||
resetAt: 0,
|
||||
ip: "1.2.3.4",
|
||||
} as never);
|
||||
vi.mocked(isCookielessNativeCaller).mockReturnValue(true);
|
||||
});
|
||||
|
||||
describe("POST /api/auth/oidc/native/token", () => {
|
||||
it("429 when rate-limited (before any state is touched)", async () => {
|
||||
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: 0,
|
||||
ip: "1.2.3.4",
|
||||
} as never);
|
||||
const res = await POST(makeRequest());
|
||||
expect(res.status).toBe(429);
|
||||
expect(consumeNativeHandoff).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("401 and never consumes when the transport is not cookie-less native", async () => {
|
||||
vi.mocked(isCookielessNativeCaller).mockReturnValue(false);
|
||||
const res = await POST(makeRequest());
|
||||
expect(res.status).toBe(401);
|
||||
expect(consumeNativeHandoff).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("422 on a malformed body", async () => {
|
||||
const res = await POST(makeRequest({ code: "nope", codeVerifier: "x" }));
|
||||
expect(res.status).toBe(422);
|
||||
expect(consumeNativeHandoff).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns ONE identical generic 401 across every invalid-code class", async () => {
|
||||
const statuses = [
|
||||
{ status: "not_found" },
|
||||
{ status: "replayed", userId: "user-1" },
|
||||
{ status: "expired" },
|
||||
{ status: "pkce_mismatch" },
|
||||
{ status: "race_lost" },
|
||||
] as const;
|
||||
|
||||
const bodies: string[] = [];
|
||||
for (const s of statuses) {
|
||||
vi.mocked(consumeNativeHandoff).mockResolvedValue(s as never);
|
||||
const res = await POST(makeRequest());
|
||||
expect(res.status).toBe(401);
|
||||
bodies.push(await res.text());
|
||||
}
|
||||
// Also the deleted-user-mid-flight branch.
|
||||
vi.mocked(consumeNativeHandoff).mockResolvedValue({
|
||||
status: "ok",
|
||||
userId: "user-1",
|
||||
handoffId: "ho-1",
|
||||
} as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never);
|
||||
const res = await POST(makeRequest());
|
||||
expect(res.status).toBe(401);
|
||||
bodies.push(await res.text());
|
||||
|
||||
// Byte-identical envelope — a code-guesser learns nothing.
|
||||
expect(new Set(bodies).size).toBe(1);
|
||||
});
|
||||
|
||||
it("does not mint on the deleted-user branch", async () => {
|
||||
vi.mocked(consumeNativeHandoff).mockResolvedValue({
|
||||
status: "ok",
|
||||
userId: "user-1",
|
||||
handoffId: "ho-1",
|
||||
} as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never);
|
||||
await POST(makeRequest());
|
||||
expect(finishLogin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("200 on success: mints via the native source, stamps the issued hash, audits", async () => {
|
||||
vi.mocked(consumeNativeHandoff).mockResolvedValue({
|
||||
status: "ok",
|
||||
userId: "user-1",
|
||||
handoffId: "ho-1",
|
||||
} as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
id: "user-1",
|
||||
username: "alice",
|
||||
onboardingCompletedAt: new Date(),
|
||||
} as never);
|
||||
vi.mocked(finishLogin).mockResolvedValue(nativeBundleResponse());
|
||||
|
||||
const res = await POST(
|
||||
makeRequest(VALID_BODY, { "x-client-type": "native" }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const finishArg = vi.mocked(finishLogin).mock.calls[0][0];
|
||||
expect(finishArg.source).toBe("login.oidc.native");
|
||||
// `mfaVerified` is never set — the SSO login can't satisfy step-up.
|
||||
expect(finishArg.mfaVerified).toBeUndefined();
|
||||
|
||||
// The issued refresh token's hash is stamped for replay reach-back.
|
||||
expect(stampIssuedRefreshToken).toHaveBeenCalledWith("ho-1", "hlr_refresh");
|
||||
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"auth.oidc.login",
|
||||
expect.objectContaining({
|
||||
userId: "user-1",
|
||||
details: { transport: "native" },
|
||||
}),
|
||||
);
|
||||
|
||||
// The client still receives the bundle (clone left the stream intact).
|
||||
const body = await res.json();
|
||||
expect(body.data.refreshToken).toBe("hlr_refresh");
|
||||
});
|
||||
|
||||
it("is NOT blocked by OIDC_ONLY (it completes an OIDC-initiated login)", async () => {
|
||||
const prev = process.env.OIDC_ONLY;
|
||||
process.env.OIDC_ONLY = "true";
|
||||
try {
|
||||
vi.mocked(consumeNativeHandoff).mockResolvedValue({
|
||||
status: "ok",
|
||||
userId: "user-1",
|
||||
handoffId: "ho-1",
|
||||
} as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
id: "user-1",
|
||||
username: "alice",
|
||||
onboardingCompletedAt: new Date(),
|
||||
} as never);
|
||||
vi.mocked(finishLogin).mockResolvedValue(nativeBundleResponse());
|
||||
|
||||
const res = await POST(
|
||||
makeRequest(VALID_BODY, { "x-client-type": "native" }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.OIDC_ONLY;
|
||||
else process.env.OIDC_ONLY = prev;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,227 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
oidcNativeHandoff: {
|
||||
create: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
// Deterministic stand-in for the real HMAC — length-keyed so it never contains
|
||||
// the raw code, making the "raw code is never persisted" assertion meaningful.
|
||||
vi.mock("@/lib/auth/hmac", () => ({
|
||||
hashToken: vi.fn((t: string) => `H(${t.length})`),
|
||||
}));
|
||||
|
||||
const revokeRefreshTokenByHash = vi.fn();
|
||||
vi.mock("@/lib/auth/refresh-token", () => ({
|
||||
revokeRefreshTokenByHash: (h: string) => revokeRefreshTokenByHash(h),
|
||||
}));
|
||||
|
||||
const auditLog = vi.fn();
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: (...a: unknown[]) => auditLog(...a),
|
||||
}));
|
||||
|
||||
import {
|
||||
mintNativeHandoff,
|
||||
consumeNativeHandoff,
|
||||
buildNativeCallbackUrl,
|
||||
stampIssuedRefreshToken,
|
||||
NATIVE_OIDC_REDIRECT_URI,
|
||||
OIDC_NATIVE_HANDOFF_TTL_MS,
|
||||
} from "../oidc-native-handoff";
|
||||
import { s256Challenge } from "@/lib/mcp/oauth/pkce";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
// A real RFC 7636 verifier + its real S256 challenge, so the constant-time
|
||||
// verify inside `consumeNativeHandoff` runs for real.
|
||||
const VERIFIER = "a".repeat(43);
|
||||
const CHALLENGE = s256Challenge(VERIFIER);
|
||||
|
||||
function storedRow(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "ho-1",
|
||||
userId: "user-1",
|
||||
codeHash: "H(47)",
|
||||
codeChallenge: CHALLENGE,
|
||||
expiresAt: new Date(Date.now() + OIDC_NATIVE_HANDOFF_TTL_MS),
|
||||
consumedAt: null,
|
||||
issuedRefreshTokenHash: null,
|
||||
createdAt: new Date(),
|
||||
ipAddress: "1.2.3.4",
|
||||
userAgent: "HealthLog-iOS/1",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("mintNativeHandoff", () => {
|
||||
it("mints an hlh_ code, stores only its hash, and binds the challenge + TTL", async () => {
|
||||
vi.mocked(prisma.oidcNativeHandoff.create).mockResolvedValue({
|
||||
id: "ho-1",
|
||||
} as never);
|
||||
|
||||
const before = Date.now();
|
||||
const { code, handoffId } = await mintNativeHandoff({
|
||||
userId: "user-1",
|
||||
appCodeChallenge: CHALLENGE,
|
||||
ipAddress: "1.2.3.4",
|
||||
userAgent: "HealthLog-iOS/1",
|
||||
});
|
||||
|
||||
expect(code.startsWith("hlh_")).toBe(true);
|
||||
expect(handoffId).toBe("ho-1");
|
||||
|
||||
const arg = vi.mocked(prisma.oidcNativeHandoff.create).mock.calls[0][0];
|
||||
// userId comes from the resolved identity; challenge bound at mint.
|
||||
expect(arg.data.userId).toBe("user-1");
|
||||
expect(arg.data.codeChallenge).toBe(CHALLENGE);
|
||||
// Only the hash is persisted — never the raw code.
|
||||
expect(arg.data.codeHash).toBe(`H(${code.length})`);
|
||||
expect(JSON.stringify(arg)).not.toContain(code);
|
||||
// 90-second TTL.
|
||||
const ttl = (arg.data.expiresAt as Date).getTime() - before;
|
||||
expect(ttl).toBeGreaterThan(OIDC_NATIVE_HANDOFF_TTL_MS - 2000);
|
||||
expect(ttl).toBeLessThanOrEqual(OIDC_NATIVE_HANDOFF_TTL_MS + 100);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildNativeCallbackUrl", () => {
|
||||
it("targets the fixed scheme and escapes params", () => {
|
||||
const url = buildNativeCallbackUrl({
|
||||
code: "hlh_abc",
|
||||
methods: "totp,recovery",
|
||||
});
|
||||
expect(url.startsWith(`${NATIVE_OIDC_REDIRECT_URI}?`)).toBe(true);
|
||||
expect(url).toContain("code=hlh_abc");
|
||||
// Comma is percent-escaped — no unescaped client value in the Location.
|
||||
expect(url).toContain("methods=totp%2Crecovery");
|
||||
});
|
||||
});
|
||||
|
||||
describe("consumeNativeHandoff", () => {
|
||||
it("returns not_found for an unknown code", async () => {
|
||||
vi.mocked(prisma.oidcNativeHandoff.findUnique).mockResolvedValue(
|
||||
null as never,
|
||||
);
|
||||
const res = await consumeNativeHandoff("hlh_" + "x".repeat(43), VERIFIER);
|
||||
expect(res.status).toBe("not_found");
|
||||
expect(
|
||||
vi.mocked(prisma.oidcNativeHandoff.findUnique).mock.calls[0][0],
|
||||
).toMatchObject({ where: { codeHash: "H(47)" } });
|
||||
});
|
||||
|
||||
it("replay: a consumed row revokes exactly the issued pair and audits", async () => {
|
||||
vi.mocked(prisma.oidcNativeHandoff.findUnique).mockResolvedValue(
|
||||
storedRow({
|
||||
consumedAt: new Date(),
|
||||
issuedRefreshTokenHash: "issued-refresh-hash",
|
||||
}) as never,
|
||||
);
|
||||
const res = await consumeNativeHandoff("hlh_" + "x".repeat(43), VERIFIER);
|
||||
expect(res.status).toBe("replayed");
|
||||
expect(revokeRefreshTokenByHash).toHaveBeenCalledWith(
|
||||
"issued-refresh-hash",
|
||||
);
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"auth.oidc.native.handoff_replay",
|
||||
expect.objectContaining({ userId: "user-1" }),
|
||||
);
|
||||
// A consumed row is never re-consumed.
|
||||
expect(prisma.oidcNativeHandoff.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("replay without an issued hash still audits but revokes nothing", async () => {
|
||||
vi.mocked(prisma.oidcNativeHandoff.findUnique).mockResolvedValue(
|
||||
storedRow({
|
||||
consumedAt: new Date(),
|
||||
issuedRefreshTokenHash: null,
|
||||
}) as never,
|
||||
);
|
||||
const res = await consumeNativeHandoff("hlh_" + "x".repeat(43), VERIFIER);
|
||||
expect(res.status).toBe("replayed");
|
||||
expect(revokeRefreshTokenByHash).not.toHaveBeenCalled();
|
||||
expect(auditLog).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("expired: burns the row (guarded) and never issues", async () => {
|
||||
vi.mocked(prisma.oidcNativeHandoff.findUnique).mockResolvedValue(
|
||||
storedRow({ expiresAt: new Date(Date.now() - 1000) }) as never,
|
||||
);
|
||||
vi.mocked(prisma.oidcNativeHandoff.updateMany).mockResolvedValue({
|
||||
count: 1,
|
||||
} as never);
|
||||
const res = await consumeNativeHandoff("hlh_" + "x".repeat(43), VERIFIER);
|
||||
expect(res.status).toBe("expired");
|
||||
expect(prisma.oidcNativeHandoff.updateMany).toHaveBeenCalledWith({
|
||||
where: { id: "ho-1", consumedAt: null },
|
||||
data: { consumedAt: expect.any(Date) },
|
||||
});
|
||||
expect(revokeRefreshTokenByHash).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("pkce_mismatch: a wrong verifier burns the code", async () => {
|
||||
vi.mocked(prisma.oidcNativeHandoff.findUnique).mockResolvedValue(
|
||||
storedRow() as never,
|
||||
);
|
||||
vi.mocked(prisma.oidcNativeHandoff.updateMany).mockResolvedValue({
|
||||
count: 1,
|
||||
} as never);
|
||||
const res = await consumeNativeHandoff(
|
||||
"hlh_" + "x".repeat(43),
|
||||
"b".repeat(43),
|
||||
);
|
||||
expect(res.status).toBe("pkce_mismatch");
|
||||
// Burned via the guarded update — no issuance.
|
||||
expect(prisma.oidcNativeHandoff.updateMany).toHaveBeenCalledWith({
|
||||
where: { id: "ho-1", consumedAt: null },
|
||||
data: { consumedAt: expect.any(Date) },
|
||||
});
|
||||
});
|
||||
|
||||
it("ok: a live row + correct verifier consumes atomically and returns the identity", async () => {
|
||||
vi.mocked(prisma.oidcNativeHandoff.findUnique).mockResolvedValue(
|
||||
storedRow() as never,
|
||||
);
|
||||
vi.mocked(prisma.oidcNativeHandoff.updateMany).mockResolvedValue({
|
||||
count: 1,
|
||||
} as never);
|
||||
const res = await consumeNativeHandoff("hlh_" + "x".repeat(43), VERIFIER);
|
||||
expect(res).toEqual({ status: "ok", userId: "user-1", handoffId: "ho-1" });
|
||||
expect(prisma.oidcNativeHandoff.updateMany).toHaveBeenCalledWith({
|
||||
where: { id: "ho-1", consumedAt: null },
|
||||
data: { consumedAt: expect.any(Date) },
|
||||
});
|
||||
});
|
||||
|
||||
it("race_lost: a lost consume returns race_lost and revokes nothing", async () => {
|
||||
vi.mocked(prisma.oidcNativeHandoff.findUnique).mockResolvedValue(
|
||||
storedRow() as never,
|
||||
);
|
||||
vi.mocked(prisma.oidcNativeHandoff.updateMany).mockResolvedValue({
|
||||
count: 0,
|
||||
} as never);
|
||||
const res = await consumeNativeHandoff("hlh_" + "x".repeat(43), VERIFIER);
|
||||
expect(res.status).toBe("race_lost");
|
||||
expect(revokeRefreshTokenByHash).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("stampIssuedRefreshToken", () => {
|
||||
it("stores the hash of the issued refresh token on the handoff row", async () => {
|
||||
vi.mocked(prisma.oidcNativeHandoff.update).mockResolvedValue({} as never);
|
||||
await stampIssuedRefreshToken("ho-1", "hlr_secret");
|
||||
expect(prisma.oidcNativeHandoff.update).toHaveBeenCalledWith({
|
||||
where: { id: "ho-1" },
|
||||
data: { issuedRefreshTokenHash: `H(${"hlr_secret".length})` },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -133,6 +133,7 @@ import {
|
||||
issueAccessAndRefresh,
|
||||
rotateRefreshToken,
|
||||
revokeRefreshToken,
|
||||
revokeRefreshTokenByHash,
|
||||
revokeBearerAccessToken,
|
||||
} from "../refresh-token";
|
||||
import type { TokenPolicyDecision } from "../native-client";
|
||||
@@ -461,3 +462,42 @@ describe("revokeBearerAccessToken (M-2 logout bearer revoke)", () => {
|
||||
expect(ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("revokeRefreshTokenByHash (native OIDC handoff replay containment)", () => {
|
||||
it("revokes the refresh row + its paired access token by stored hash", async () => {
|
||||
const bundle = await issueAccessAndRefresh({
|
||||
userId: "u1",
|
||||
policy: NATIVE_POLICY,
|
||||
source: "login.oidc.native",
|
||||
deviceId: "dev-1",
|
||||
});
|
||||
const refreshHash = `hash:${bundle.refreshToken}`;
|
||||
const accessHash = `hash:${bundle.accessToken}`;
|
||||
|
||||
// The handoff row only ever stored the HASH of the refresh token, never
|
||||
// the raw secret — so replay containment must revoke by hash.
|
||||
const ok = await revokeRefreshTokenByHash(refreshHash);
|
||||
expect(ok).toBe(true);
|
||||
|
||||
expect(
|
||||
dbState.refreshTokens.find((r) => r.tokenHash === refreshHash)?.revokedAt,
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
dbState.apiTokens.find((a) => a.tokenHash === accessHash)?.revoked,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for an unknown or already-revoked hash", async () => {
|
||||
expect(await revokeRefreshTokenByHash("hash:hlr_unknown")).toBe(false);
|
||||
|
||||
const bundle = await issueAccessAndRefresh({
|
||||
userId: "u1",
|
||||
policy: NATIVE_POLICY,
|
||||
source: "login.oidc.native",
|
||||
});
|
||||
const refreshHash = `hash:${bundle.refreshToken}`;
|
||||
await revokeRefreshTokenByHash(refreshHash);
|
||||
// Second call is a no-op (idempotent containment).
|
||||
expect(await revokeRefreshTokenByHash(refreshHash)).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user