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.");