diff --git a/website/app/auth/AuthCallback.tsx b/website/app/auth/AuthCallback.tsx index 7c157d5..663be60 100644 --- a/website/app/auth/AuthCallback.tsx +++ b/website/app/auth/AuthCallback.tsx @@ -2,7 +2,7 @@ import Link from "next/link"; import { useEffect, useRef, useState } from "react"; -import { completeMystiraIdentityFlow } from "./oidc"; +import { completeMystiraIdentityFlow, consumeMystiraIdentityRequest } from "./oidc"; type CallbackState = "loading" | "success" | "error"; @@ -25,19 +25,37 @@ export default function AuthCallback() { const returnedState = params.get("state"); if (error) { - setState("error"); - setMessage(params.get("error_description") ?? error); + try { + consumeMystiraIdentityRequest(returnedState); + window.history.replaceState({}, document.title, "/auth/callback"); + setState("error"); + setMessage( + error === "access_denied" + ? "Sign-in was cancelled." + : "Mystira Identity did not complete sign-in.", + ); + } catch (ex) { + setState("error"); + setMessage(ex instanceof Error ? ex.message : "Unable to validate sign-in."); + } return; } - if (!code || !returnedState) { - setState("error"); - setMessage("Mystira Identity did not return an authorization code."); + if (!code) { + try { + consumeMystiraIdentityRequest(returnedState); + window.history.replaceState({}, document.title, "/auth/callback"); + setState("error"); + setMessage("Mystira Identity did not return an authorization code."); + } catch (ex) { + setState("error"); + setMessage(ex instanceof Error ? ex.message : "Unable to validate sign-in."); + } return; } try { - await completeMystiraIdentityFlow(code, returnedState); + await completeMystiraIdentityFlow(code, returnedState ?? ""); window.history.replaceState({}, document.title, "/auth/callback"); setState("success"); setMessage("Signed in with Mystira Identity."); diff --git a/website/app/auth/__tests__/AuthCallback.test.tsx b/website/app/auth/__tests__/AuthCallback.test.tsx new file mode 100644 index 0000000..707c70e --- /dev/null +++ b/website/app/auth/__tests__/AuthCallback.test.tsx @@ -0,0 +1,48 @@ +import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it } from "vitest"; +import AuthCallback from "../AuthCallback"; +import { CODEFLOW_AUTH_STATE_KEY } from "../oidc"; + +const storedRequest = { + intent: "login", + state: "expected-state", + nonce: "nonce", + codeVerifier: "verifier", + redirectUri: "https://codeflow.phoenixvc.tech/auth/callback", + createdAt: Date.now(), +}; + +describe("AuthCallback", () => { + beforeEach(() => { + sessionStorage.clear(); + sessionStorage.setItem(CODEFLOW_AUTH_STATE_KEY, JSON.stringify(storedRequest)); + }); + + it("consumes a matched cancellation without rendering the provider description", async () => { + window.history.replaceState( + {}, + "", + "/auth/callback?error=access_denied&error_description=Attacker-controlled&state=expected-state", + ); + + render(); + + expect(await screen.findByText("Sign-in was cancelled.")).toBeInTheDocument(); + expect(screen.queryByText("Attacker-controlled")).not.toBeInTheDocument(); + expect(sessionStorage.getItem(CODEFLOW_AUTH_STATE_KEY)).toBeNull(); + }); + + it("rejects a provider error when the callback state does not match", async () => { + window.history.replaceState( + {}, + "", + "/auth/callback?error=access_denied&error_description=User+cancelled&state=wrong-state", + ); + + render(); + + expect(await screen.findByText("Sign-in state did not match. Start login again.")).toBeInTheDocument(); + expect(screen.queryByText("User cancelled")).not.toBeInTheDocument(); + expect(sessionStorage.getItem(CODEFLOW_AUTH_STATE_KEY)).not.toBeNull(); + }); +}); diff --git a/website/app/auth/oidc.ts b/website/app/auth/oidc.ts index 13e049f..dc25e56 100644 --- a/website/app/auth/oidc.ts +++ b/website/app/auth/oidc.ts @@ -126,15 +126,7 @@ export async function startMystiraIdentityFlow(intent: CodeflowAuthIntent): Prom } export async function completeMystiraIdentityFlow(code: string, state: string): Promise { - const rawRequest = sessionStorage.getItem(CODEFLOW_AUTH_STATE_KEY); - if (!rawRequest) { - throw new Error("Missing saved sign-in request. Start login again."); - } - - const request = JSON.parse(rawRequest) as StoredAuthRequest; - if (request.state !== state) { - throw new Error("Sign-in state did not match. Start login again."); - } + const request = getStoredAuthRequest(state); const body = new URLSearchParams(); body.set("grant_type", "authorization_code"); @@ -162,6 +154,25 @@ export async function completeMystiraIdentityFlow(code: string, state: string): return tokenResponse; } +export function consumeMystiraIdentityRequest(state: string | null): void { + getStoredAuthRequest(state); + sessionStorage.removeItem(CODEFLOW_AUTH_STATE_KEY); +} + +function getStoredAuthRequest(state: string | null): StoredAuthRequest { + const rawRequest = sessionStorage.getItem(CODEFLOW_AUTH_STATE_KEY); + if (!rawRequest) { + throw new Error("Missing saved sign-in request. Start login again."); + } + + const request = JSON.parse(rawRequest) as StoredAuthRequest; + if (request.state !== state) { + throw new Error("Sign-in state did not match. Start login again."); + } + + return request; +} + async function validateIdToken(idToken: string | undefined, request: StoredAuthRequest): Promise { if (!idToken) { throw new Error("Mystira Identity did not return an ID token.");