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
77 changes: 77 additions & 0 deletions website/app/auth/AuthCallback.tsx
Original file line number Diff line number Diff line change
@@ -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<CallbackState>("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();
}, []);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return (
<div className="mx-auto max-w-xl text-center">
<h1 className="mb-4 text-4xl font-bold text-slate-900 dark:text-slate-50">
{state === "success" ? "You're signed in" : state === "error" ? "Sign-in needs attention" : "Signing you in"}
</h1>
<p className="mb-8 text-lg text-slate-600 dark:text-slate-400">{message}</p>
<div className="flex flex-col items-center justify-center gap-4 sm:flex-row">
{state === "success" ? (
<Link
href="/integration"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Forward successful sign-ins to the app

For the alpha CTAs now routed through /login, a successful callback only stores the token response in this site's sessionStorage and then sends the user to /integration, which is the documentation flow rather than the deployed app; a repo-wide search shows CODEFLOW_SESSION_KEY is only used in auth/oidc.ts, and sessionStorage will not carry over to https://app.codeflow.io. As a result, users who click “Try Alpha Preview” can complete Identity login but the actual CodeFlow app never receives that authenticated state. Redirect to the app with a real handoff, or make the app origin own the callback/session.

Useful? React with 👍 / 👎.

className="rounded-lg bg-gradient-to-r from-blue-600 to-purple-600 px-8 py-3 text-lg font-semibold text-white transition-all hover:from-blue-700 hover:to-purple-700 hover:shadow-lg"
>
Continue to CodeFlow
</Link>
) : null}
<Link
href="/"
className="rounded-lg border-2 border-slate-300 bg-white/50 px-8 py-3 text-lg font-semibold text-slate-900 backdrop-blur-sm transition-colors hover:border-slate-400 dark:border-slate-600 dark:bg-slate-800/50 dark:text-slate-50 dark:hover:border-slate-500"
>
Back to home
</Link>
</div>
</div>
);
}
41 changes: 41 additions & 0 deletions website/app/auth/AuthStart.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(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 (
<div className="mx-auto flex max-w-xl flex-col items-center text-center">
<Button as="button" size="lg" onClick={handleStart} disabled={isStarting}>
{isStarting ? "Opening Mystira Identity..." : `${label} with Mystira Identity`}
</Button>
{error ? (
<p className="mt-4 text-sm text-red-600 dark:text-red-400" role="alert">
{error}
</p>
) : null}
</div>
);
}
15 changes: 15 additions & 0 deletions website/app/auth/callback/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import AuthCallback from "../AuthCallback";
import Footer from "../../components/Footer";
import Header from "../../components/Header";

export default function AuthCallbackPage() {
return (
<div className="flex min-h-screen flex-col bg-gradient-to-b from-slate-50/80 to-slate-100/50 dark:from-slate-900/80 dark:to-slate-950/50">
<Header />
<main className="flex flex-1 items-center px-6 py-24">
<AuthCallback />
</main>
<Footer />
</div>
);
}
223 changes: 223 additions & 0 deletions website/app/auth/oidc.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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<T>(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<void> {
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");

Comment on lines +103 to +124

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

What authorization-request parameter does Mystira Identity require to open account registration or a signup screen for an OpenID Connect public client?

💡 Result:

To open the account registration or signup screen within an OpenID Connect authorization request, Mystira Identity—like other standards-compliant OpenID Connect providers—uses the prompt parameter with the value create [1][2]. Specifically, the "Initiating User Registration via OpenID Connect 1.0" specification defines the prompt=create parameter [1]. When a client includes prompt=create in its authorization request, it instructs the OpenID Provider to present the user with an account creation interface rather than the standard login flow [1][2]. After the registration process is completed by the user, the identity provider redirects them back to the client application to finish the authentication flow [1][2].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate file =="
fd -a 'oidc\.ts$' . | sed 's#^\./##'

echo "== inspect target =="
sed -n '1,130p' website/app/auth/oidc.ts

echo "== references to signup/auth flow =="
rg -n "intent|signup|/signup|CREATE|create|prompt|StoredAuthRequest|CODEFLOW_AUTH_STATE_KEY" website/app website || true

Repository: phoenixvc/codeflow-engine

Length of output: 17580


🌐 Web query:

Mystira Identity prompt=create OpenID Connect user registration

💡 Result:

Mystira is an early-childhood developmental observation platform founded in 2025 that utilizes interactive storytelling to assess children [1][2]. Technical documentation and source code references indicate that Mystira operates as an OpenID Connect (OIDC) identity provider, allowing third-party applications to integrate authentication via standard OIDC flows [3][4][5]. For developers implementing user registration or authentication using Mystira as an IdP, the following technical requirements and practices are observed in existing implementations: 1. Authentication Integration: Applications typically integrate Mystira using frameworks like Auth.js v5 [3][5]. The authentication flow replaces legacy credential-based systems (such as bcrypt/JWT) with OIDC-based login, redirecting users to the Mystira platform to complete authentication [3][5]. 2. Account Linking and Security: In internal implementations, account linking is handled by matching the user's email address [4]. To prevent security vulnerabilities such as account takeover or privilege escalation, systems are required to validate the email_verified claim (accepting either boolean true or the string "true") before linking the OIDC identity to a local user account [4]. 3. Future Roadmap: Developers are encouraged to migrate from email-based matching to matching on the immutable sub (subject) claim to improve security and robustness [4]. Because Mystira functions as an OIDC provider, user registration is managed at the Mystira platform level (e.g., via their interactive app/dashboard) rather than through a direct API-based registration prompt in the consuming application [3][1]. Applications trigger the flow by initiating an OIDC sign-in request, which offloads the registration and login process to the Mystira-hosted service [3].

Citations:


Make the signup intent change the IDP prompt.

intent is only persisted, so both login and signup request /connect/authorize?prompt=login. Set prompt to the provider’s registration value for signup—typically prompt=create for OpenID Connect flows—to request account creation rather than login.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@website/app/auth/oidc.ts` around lines 61 - 82, Update the authorization URL
construction around the `intent` and `authorizeUrl` symbols so the `prompt`
parameter uses the provider’s registration value (`create`) when `intent` is
signup, while retaining `login` for normal authentication. Keep the persisted
`StoredAuthRequest` behavior unchanged.

window.location.assign(authorizeUrl.toString());
}

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 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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

async function validateIdToken(idToken: string | undefined, request: StoredAuthRequest): Promise<void> {
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<JwtHeader>(encodedHeader);
const claims = decodeJwtPart<IdTokenClaims>(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.");
}
}
16 changes: 15 additions & 1 deletion website/app/components/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -61,6 +61,20 @@ export default function Header({ currentPage = 'home' }: HeaderProps) {
>
GitHub
</a>
<Link
href="/login"
className={navLinkClass('login')}
aria-current={isCurrentPage('login') ? 'page' : undefined}
>
Sign in
</Link>
<Link
href="/signup"
className="rounded-lg bg-gradient-to-r from-blue-600 to-purple-600 px-4 py-2 font-semibold text-white transition-all hover:from-blue-700 hover:to-purple-700 hover:shadow-lg"
aria-current={isCurrentPage('signup') ? 'page' : undefined}
>
Sign up
</Link>
<ThemeToggle />
</div>
</nav>
Expand Down
Loading
Loading