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
32 changes: 25 additions & 7 deletions website/app/auth/AuthCallback.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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.");
Expand Down
48 changes: 48 additions & 0 deletions website/app/auth/__tests__/AuthCallback.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<AuthCallback />);

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(<AuthCallback />);

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();
});
});
29 changes: 20 additions & 9 deletions website/app/auth/oidc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,15 +126,7 @@ export async function startMystiraIdentityFlow(intent: CodeflowAuthIntent): Prom
}

export async function completeMystiraIdentityFlow(code: string, state: string): Promise<TokenResponse> {
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");
Expand Down Expand Up @@ -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<void> {
if (!idToken) {
throw new Error("Mystira Identity did not return an ID token.");
Expand Down
Loading