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 (
+
+
+ {isStarting ? "Opening Mystira Identity..." : `${label} with Mystira Identity`}
+
+ {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.