From 1f2141d691dbd70757460222a6cb6fc7aaff0cd0 Mon Sep 17 00:00:00 2001 From: Jurie Smit Date: Fri, 24 Jul 2026 12:28:21 +0200 Subject: [PATCH] feat(website): route auth through mystira identity --- website/app/auth/AuthCallback.tsx | 77 ++++++++++ website/app/auth/AuthStart.tsx | 41 ++++++ website/app/auth/callback/page.tsx | 15 ++ website/app/auth/oidc.ts | 223 +++++++++++++++++++++++++++++ website/app/components/Header.tsx | 16 ++- website/app/login/page.tsx | 23 +++ website/app/page.tsx | 27 ++-- website/app/signup/page.tsx | 23 +++ 8 files changed, 427 insertions(+), 18 deletions(-) create mode 100644 website/app/auth/AuthCallback.tsx create mode 100644 website/app/auth/AuthStart.tsx create mode 100644 website/app/auth/callback/page.tsx create mode 100644 website/app/auth/oidc.ts create mode 100644 website/app/login/page.tsx create mode 100644 website/app/signup/page.tsx diff --git a/website/app/auth/AuthCallback.tsx b/website/app/auth/AuthCallback.tsx new file mode 100644 index 0000000..7c157d5 --- /dev/null +++ b/website/app/auth/AuthCallback.tsx @@ -0,0 +1,77 @@ +"use client"; + +import Link from "next/link"; +import { useEffect, useRef, useState } from "react"; +import { completeMystiraIdentityFlow } from "./oidc"; + +type CallbackState = "loading" | "success" | "error"; + +export default function AuthCallback() { + const [state, setState] = useState("loading"); + const [message, setMessage] = useState("Completing secure sign-in..."); + const hasStarted = useRef(false); + + useEffect(() => { + if (hasStarted.current) { + return; + } + + hasStarted.current = true; + + async function complete() { + const params = new URLSearchParams(window.location.search); + const error = params.get("error"); + const code = params.get("code"); + const returnedState = params.get("state"); + + if (error) { + setState("error"); + setMessage(params.get("error_description") ?? error); + return; + } + + if (!code || !returnedState) { + setState("error"); + setMessage("Mystira Identity did not return an authorization code."); + return; + } + + try { + await completeMystiraIdentityFlow(code, returnedState); + window.history.replaceState({}, document.title, "/auth/callback"); + setState("success"); + setMessage("Signed in with Mystira Identity."); + } catch (ex) { + setState("error"); + setMessage(ex instanceof Error ? ex.message : "Unable to complete sign-in."); + } + } + + void complete(); + }, []); + + return ( +
+

+ {state === "success" ? "You're signed in" : state === "error" ? "Sign-in needs attention" : "Signing you in"} +

+

{message}

+
+ {state === "success" ? ( + + Continue to CodeFlow + + ) : null} + + Back to home + +
+
+ ); +} diff --git a/website/app/auth/AuthStart.tsx b/website/app/auth/AuthStart.tsx new file mode 100644 index 0000000..e5c54d2 --- /dev/null +++ b/website/app/auth/AuthStart.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { useState } from "react"; +import Button from "../components/Button"; +import { startMystiraIdentityFlow, type CodeflowAuthIntent } from "./oidc"; + +interface AuthStartProps { + intent: CodeflowAuthIntent; +} + +export default function AuthStart({ intent }: AuthStartProps) { + const [isStarting, setIsStarting] = useState(false); + const [error, setError] = useState(null); + + async function handleStart() { + setIsStarting(true); + setError(null); + + try { + await startMystiraIdentityFlow(intent); + } catch (ex) { + setIsStarting(false); + setError(ex instanceof Error ? ex.message : "Unable to start Mystira Identity."); + } + } + + const label = intent === "signup" ? "Create account" : "Sign in"; + + return ( +
+ + {error ? ( +

+ {error} +

+ ) : null} +
+ ); +} diff --git a/website/app/auth/callback/page.tsx b/website/app/auth/callback/page.tsx new file mode 100644 index 0000000..ccacd5f --- /dev/null +++ b/website/app/auth/callback/page.tsx @@ -0,0 +1,15 @@ +import AuthCallback from "../AuthCallback"; +import Footer from "../../components/Footer"; +import Header from "../../components/Header"; + +export default function AuthCallbackPage() { + return ( +
+
+
+ +
+
+
+ ); +} diff --git a/website/app/auth/oidc.ts b/website/app/auth/oidc.ts new file mode 100644 index 0000000..13e049f --- /dev/null +++ b/website/app/auth/oidc.ts @@ -0,0 +1,223 @@ +export const MYSTIRA_IDENTITY_BASE_URL = "https://identity.mystira.app"; +export const CODEFLOW_WEBSITE_CLIENT_ID = "phoenixvc-codeflow-website"; +export const CODEFLOW_AUTH_STATE_KEY = "codeflow.mystira.auth"; +export const CODEFLOW_SESSION_KEY = "codeflow.mystira.session"; + +export type CodeflowAuthIntent = "login" | "signup"; + +interface StoredAuthRequest { + intent: CodeflowAuthIntent; + state: string; + nonce: string; + codeVerifier: string; + redirectUri: string; + createdAt: number; +} + +export interface TokenResponse { + access_token?: string; + expires_in?: number; + id_token?: string; + scope?: string; + token_type?: string; +} + +interface JwtHeader { + alg?: string; + kid?: string; +} + +interface IdTokenClaims { + aud?: string | string[]; + exp?: number; + iss?: string; + nonce?: string; +} + +interface MystiraJsonWebKey extends JsonWebKey { + kid?: string; +} + +interface JsonWebKeySet { + keys?: MystiraJsonWebKey[]; +} + +function base64UrlEncode(bytes: Uint8Array): string { + let binary = ""; + bytes.forEach((byte) => { + binary += String.fromCharCode(byte); + }); + + return btoa(binary) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/g, ""); +} + +async function sha256Base64Url(value: string): Promise { + const encoded = new TextEncoder().encode(value); + const digest = await crypto.subtle.digest("SHA-256", encoded); + return base64UrlEncode(new Uint8Array(digest)); +} + +function randomBase64Url(byteLength: number): string { + const bytes = new Uint8Array(byteLength); + crypto.getRandomValues(bytes); + return base64UrlEncode(bytes); +} + +function decodeBase64Url(value: string): Uint8Array { + const base64 = value.replace(/-/g, "+").replace(/_/g, "/"); + const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), "="); + const binary = atob(padded); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + + return bytes; +} + +function decodeJwtPart(value: string): T { + const json = new TextDecoder().decode(decodeBase64Url(value)); + return JSON.parse(json) as T; +} + +function toArrayBuffer(bytes: Uint8Array): ArrayBuffer { + const buffer = new ArrayBuffer(bytes.byteLength); + new Uint8Array(buffer).set(bytes); + return buffer; +} + +export function getRedirectUri(): string { + return `${window.location.origin}/auth/callback`; +} + +export async function startMystiraIdentityFlow(intent: CodeflowAuthIntent): Promise { + const redirectUri = getRedirectUri(); + const state = randomBase64Url(32); + const nonce = randomBase64Url(32); + const codeVerifier = randomBase64Url(64); + const codeChallenge = await sha256Base64Url(codeVerifier); + + const storedRequest: StoredAuthRequest = { + intent, + state, + nonce, + codeVerifier, + redirectUri, + createdAt: Date.now(), + }; + + sessionStorage.setItem(CODEFLOW_AUTH_STATE_KEY, JSON.stringify(storedRequest)); + + const authorizeUrl = new URL("/connect/authorize", MYSTIRA_IDENTITY_BASE_URL); + authorizeUrl.searchParams.set("client_id", CODEFLOW_WEBSITE_CLIENT_ID); + authorizeUrl.searchParams.set("redirect_uri", redirectUri); + authorizeUrl.searchParams.set("response_type", "code"); + authorizeUrl.searchParams.set("scope", "openid profile email"); + authorizeUrl.searchParams.set("code_challenge", codeChallenge); + authorizeUrl.searchParams.set("code_challenge_method", "S256"); + authorizeUrl.searchParams.set("state", state); + authorizeUrl.searchParams.set("nonce", nonce); + authorizeUrl.searchParams.set("prompt", "login"); + + window.location.assign(authorizeUrl.toString()); +} + +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 body = new URLSearchParams(); + body.set("grant_type", "authorization_code"); + body.set("client_id", CODEFLOW_WEBSITE_CLIENT_ID); + body.set("redirect_uri", request.redirectUri); + body.set("code", code); + body.set("code_verifier", request.codeVerifier); + + const response = await fetch(`${MYSTIRA_IDENTITY_BASE_URL}/connect/token`, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body, + }); + + if (!response.ok) { + throw new Error(`Mystira Identity token exchange failed (${response.status}).`); + } + + const tokenResponse = (await response.json()) as TokenResponse; + await validateIdToken(tokenResponse.id_token, request); + sessionStorage.setItem(CODEFLOW_SESSION_KEY, JSON.stringify(tokenResponse)); + sessionStorage.removeItem(CODEFLOW_AUTH_STATE_KEY); + return tokenResponse; +} + +async function validateIdToken(idToken: string | undefined, request: StoredAuthRequest): Promise { + if (!idToken) { + throw new Error("Mystira Identity did not return an ID token."); + } + + const parts = idToken.split("."); + if (parts.length !== 3) { + throw new Error("Mystira Identity returned an invalid ID token."); + } + + const [encodedHeader, encodedPayload, encodedSignature] = parts; + const header = decodeJwtPart(encodedHeader); + const claims = decodeJwtPart(encodedPayload); + + if (header.alg !== "RS256" || !header.kid) { + throw new Error("Mystira Identity returned an unsupported ID token."); + } + + const now = Math.floor(Date.now() / 1000); + const audiences = Array.isArray(claims.aud) ? claims.aud : claims.aud ? [claims.aud] : []; + if (claims.iss !== `${MYSTIRA_IDENTITY_BASE_URL}/` + || !audiences.includes(CODEFLOW_WEBSITE_CLIENT_ID) + || !claims.exp + || claims.exp <= now + || claims.nonce !== request.nonce) { + throw new Error("Mystira Identity returned an ID token with invalid claims."); + } + + const jwksResponse = await fetch(`${MYSTIRA_IDENTITY_BASE_URL}/.well-known/jwks`, { + cache: "no-store", + }); + if (!jwksResponse.ok) { + throw new Error("Unable to load Mystira Identity signing keys."); + } + + const jwks = (await jwksResponse.json()) as JsonWebKeySet; + const jwk = jwks.keys?.find((key) => key.kid === header.kid && key.kty === "RSA"); + if (!jwk) { + throw new Error("Mystira Identity signing key was not found."); + } + + const key = await crypto.subtle.importKey( + "jwk", + jwk, + { + name: "RSASSA-PKCS1-v1_5", + hash: "SHA-256", + }, + false, + ["verify"], + ); + + const data = toArrayBuffer(new TextEncoder().encode(`${encodedHeader}.${encodedPayload}`)); + const signature = toArrayBuffer(decodeBase64Url(encodedSignature)); + const isValid = await crypto.subtle.verify("RSASSA-PKCS1-v1_5", key, signature, data); + if (!isValid) { + throw new Error("Mystira Identity ID token signature was invalid."); + } +} diff --git a/website/app/components/Header.tsx b/website/app/components/Header.tsx index e429e42..9375663 100644 --- a/website/app/components/Header.tsx +++ b/website/app/components/Header.tsx @@ -5,7 +5,7 @@ import AlphaBadge from "./AlphaBadge"; import ThemeToggle from "./ThemeToggle"; interface HeaderProps { - currentPage?: 'home' | 'installation' | 'integration' | 'download'; + currentPage?: 'home' | 'installation' | 'integration' | 'download' | 'login' | 'signup'; } export default function Header({ currentPage = 'home' }: HeaderProps) { @@ -61,6 +61,20 @@ export default function Header({ currentPage = 'home' }: HeaderProps) { > GitHub + + Sign in + + + Sign up + diff --git a/website/app/login/page.tsx b/website/app/login/page.tsx new file mode 100644 index 0000000..d116f9c --- /dev/null +++ b/website/app/login/page.tsx @@ -0,0 +1,23 @@ +import Footer from "../components/Footer"; +import Header from "../components/Header"; +import AuthStart from "../auth/AuthStart"; + +export default function LoginPage() { + return ( +
+
+
+
+

+ Sign in to CodeFlow +

+

+ Continue through Mystira Identity to access the CodeFlow alpha. +

+ +
+
+
+
+ ); +} diff --git a/website/app/page.tsx b/website/app/page.tsx index 18ba87a..845828f 100644 --- a/website/app/page.tsx +++ b/website/app/page.tsx @@ -2,7 +2,6 @@ import Link from "next/link"; import Header from "./components/Header"; import Footer from "./components/Footer"; import { FeatureCard } from "./components/FeatureCard"; -import { APP_URL } from "./config/constants"; export default function Home() { return ( @@ -24,16 +23,14 @@ export default function Home() { issue creation, and multi-agent collaboration.

- Try Alpha Preview - + Get Started @@ -85,14 +82,12 @@ export default function Home() { of AI-powered PR automation and help us improve with your feedback.

- Access Alpha Preview → - +
- Try Alpha Preview - + +
+
+
+

+ Create your CodeFlow account +

+

+ Mystira Identity will create or connect your account before returning you to CodeFlow. +

+ +
+
+
+ ); +}