diff --git a/.env.example b/.env.example index f4e7ac1..2482ac7 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,7 @@ # GitHub OAuth App client ID (public — safe to embed in the SPA bundle). # Use the dev OAuth App's id locally; the dev API is configured with the same id. +# Only the "Sign in with GitHub" path needs it. The Emergent (Entra) sign-in has +# no variables: its tenant, client id and scope are constants in src/lib/entra.ts. VITE_GITHUB_OAUTH_CLIENT_ID= # Base URL of the ATK API (no /api prefix, no trailing slash). diff --git a/PROJECT_OVERVIEW.md b/PROJECT_OVERVIEW.md index db25e07..384511b 100644 --- a/PROJECT_OVERVIEW.md +++ b/PROJECT_OVERVIEW.md @@ -46,7 +46,7 @@ Developers should continue to use the `atk` CLI. ATK Web deliberately does **not - Fields for name, description, README, type, tags. - On submit, opens a **pull request** against `agentic-toolkit-registry` (mirroring the CLI's `atk publish` flow) so the existing security review pipeline runs. - **Org support** — users can view assets scoped to their org or global assets. -- **Auth via GitHub** (the ATK API validates the token, gates on EmergentSoftware org membership, and opens PRs with it so they are authored by the user). +- **Auth via an Emergent (Microsoft Entra) account or GitHub** (the ATK API validates either token: Entra tokens for Emergent Software tenant members, GitHub tokens for EmergentSoftware org members; GitHub publishes open PRs with the user's token, Entra publishes use the API's publish identity with the commit authored as the user). ### Out of scope @@ -79,7 +79,7 @@ Intentionally small and conventional. No server in this repo — this is a fully ### ATK API Integration - **`@hey-api/openapi-ts`** — generates a typed fetch client (`src/lib/api/`) from the API's vendored OpenAPI contract (`openapi/openapi.json`). `src/lib/api-client.ts` wraps it with the base URL (`VITE_ATK_API_URL`), bearer auth from the session token, retries, and error mapping. -- Auth via a **GitHub OAuth App** (standard web flow), with the `code`-for-token exchange handled by the ATK API's `POST /auth/github/exchange` (see §7 Authentication). +- Auth via **`@azure/msal-browser`** (Microsoft Entra, auth-code + PKCE with the v5 redirect bridge page `auth-redirect.html`) and, until the cutoff, a **GitHub OAuth App** (standard web flow, `code`-for-token exchange handled by the ATK API's `POST /auth/github/exchange`). See §7 Authentication. ### Tooling - **ESLint** + **Prettier** — match the conventions used in `agentic-toolkit`. @@ -118,47 +118,44 @@ Assets and bundles may be org-scoped (`org` in the manifest; `@{org}/` in regist - **Static SPA.** Built with Vite, deployed to GitHub Pages via GitHub Actions. - **The ATK API as the backend.** Every registry read (`GET /registry`, manifests, READMEs, file listings), every download (server-built zips), and every publish goes through the shared API; the browser never talks to GitHub's REST API directly. The CLI uses the same endpoints, so both clients see identical behaviour. -- **Token passthrough.** The API validates the user's GitHub token and EmergentSoftware membership on each request and opens publish PRs with that same token, so PR authorship and the review workflow are unchanged from the CLI. The API never persists user tokens. -- **Tokens live in the browser.** Access tokens are held in `sessionStorage` and never persisted to any server we operate. +- **Token passthrough.** The API validates the user's token on each request (an Entra access token against the Emergent Software tenant, or a GitHub token against EmergentSoftware membership) and never persists it. GitHub publishes open PRs with that same token, so PR authorship is unchanged from the CLI; Entra publishes are opened by the API's publish identity with the commit authored as the user and a "Published by" line in the PR body. +- **Tokens live in the browser.** Access tokens are held in `sessionStorage` (MSAL's cache for Entra, one key for GitHub) and never persisted to any server we operate. - **Registry schema parity with the CLI.** The web app validates and renders manifests using the same Zod schemas defined in `agentic-toolkit/src/lib/schemas/`. A valid asset in the CLI is a valid asset in the web UI, and vice versa. - **No duplicate registry.** The web app reads the canonical `registry.json` published by the registry repo's CI — the same artifact the CLI consumes. ## 7. Authentication -### Approach: GitHub OAuth App + ATK API token exchange +Two providers, one session (`src/providers/SessionProvider.tsx`): the **Emergent account** (Microsoft Entra ID, primary) and **GitHub** (secondary, kept until the GitHub sign-in cutoff). Whichever the user picks, the result is a bearer token the ATK API validates on every call; the API picks the scheme from the token's shape, so the SPA never says which kind it is sending. The contract is `docs/design/Auth.md` in the monorepo. -GitHub Pages is static-only, and GitHub's OAuth token-exchange endpoint does not support CORS from arbitrary browser origins. That rules out a pure-browser OAuth handshake. The ATK API holds the OAuth App's `client_secret` and performs the one `code`-for-token exchange at `POST /auth/github/exchange` (this replaced the repo's earlier standalone `auth-function`). +### Emergent account (MSAL) -### Components +- `@azure/msal-browser` 5 behind the small `EntraClient` interface in `src/lib/entra.ts` (tests inject a fake; there is no `msal-react`). Constants in code: tenant `25ee13ae-…`, SPA client `ES ATK Web` (`07a23158-…`), scope `api://8da5aa72-…/access_as_user`. No `VITE_*` variables. +- **Redirect flow with the v5 redirect bridge.** "Sign in with your Emergent account" calls `loginRedirect` with the current `#/route` as the start page. Microsoft returns to `auth-redirect.html` (a second Vite entry whose only script calls `broadcastResponseToMainFrame()`), which hands the response to MSAL and navigates back to the start page, so the hash router never sees `#code=…`. On that load `SessionProvider` awaits `initialize()` and `handleRedirectPromise()` before reporting any status other than `verifying`. +- **Tokens.** The API client's auth callback asks MSAL for an access token on every request (`acquireTokenSilent`); MSAL serves it from its `sessionStorage` cache and refreshes it with the SPA refresh token. A failure that needs interaction ends the session with a "Your Emergent sign-in has expired" notice; the app never starts an interactive flow from inside a request. +- **Sign-out** is `logoutRedirect` with the app root as the post-logout target: clears the cache, ends the Microsoft web session, lands on the signed-out landing. -1. **GitHub OAuth App** registered under the EmergentSoftware org. - - Callback URL: the deployed GitHub Pages URL. - - Required scopes: `read:org` (to verify EmergentSoftware membership) and `repo` (to read the private registry, fork it, push to the user's fork, and open PRs). -2. **ATK API** (`func-atk-prod` / `func-atk-dev`, .NET on Azure Functions; deployed from the monorepo). - - `POST /auth/github/exchange` accepts an OAuth `code`, calls `github.com/login/oauth/access_token` with the stored `client_secret`, and returns GitHub's token response verbatim. - - The client secret lives in Key Vault; CORS is restricted to the SPA origins. - - Two OAuth Apps: the **dev** app's id is configured on the dev API (used by `pnpm dev`), the **prod** app's id on the prod API (used by GitHub Pages). -3. **SPA auth flow.** - - User clicks "Sign in with GitHub" → redirected to the OAuth App authorize screen. - - GitHub redirects back to the SPA with a `code`. - - SPA `POST`s the code to `{VITE_ATK_API_URL}/auth/github/exchange` → receives the access token. - - SPA stores the token in `sessionStorage` and sends it as `Authorization: Bearer …` on every ATK API call. +### GitHub OAuth App (until the cutoff) -### Org membership gate +GitHub Pages is static-only, and GitHub's OAuth token-exchange endpoint does not support CORS from arbitrary browser origins, so the ATK API holds the OAuth App's `client_secret` and performs the one `code`-for-token exchange at `POST /auth/github/exchange` (this replaced the repo's earlier standalone `auth-function`). "Sign in with GitHub" (or `/sign-in?provider=github`) redirects to the OAuth App's authorize screen with scopes `read:org repo`; GitHub returns to `/#/auth/callback?code=…&state=…`; the SPA checks `state`, `POST`s the code to the API, and stores the token in `sessionStorage` under `atk:session:token`. Two OAuth Apps: the **dev** app's id is configured on the dev API (used by `pnpm dev`), the **prod** app's id on the prod API (used by GitHub Pages). -Immediately after auth, the SPA calls the API's `GET /me`. The API validates the token and checks EmergentSoftware membership itself: +### Membership gate -- **`200`:** active member — proceeds into the app; the response supplies the login, name, and avatar for display. -- **`403 not_org_member` / `org_membership_unverifiable`:** shown a friendly blocking screen explaining they must be a member of EmergentSoftware to use this tool, with contact guidance for being added (the unverifiable case logs SAML / OAuth-App-approval hints to the console). -- **`401`:** the stored token is dead; the app returns to the signed-out landing. +Immediately after sign-in the SPA calls the API's `GET /me`, which validates the token and enforces membership itself: + +- **`200`:** allowed in. The response is the principal (`scheme`, `id`, `login`, `name?`, `email?`, `avatarUrl?`, `githubAuthSunset?`); the header shows `login` (the UPN for Entra users, the GitHub login otherwise). +- **`403 not_org_member` / `org_membership_unverifiable`** (GitHub) or **`403 guest_not_allowed`** (Entra): the blocking "Not authorized" page, with copy per scheme (the unverifiable case logs SAML / OAuth-App-approval hints to the console). +- **`401`:** the token is dead; the app returns to the signed-out landing. `401 github_auth_retired` (after the cutoff) keeps the API's message as a notice on the landing. + +### GitHub sign-in cutoff + +While the API has a cutoff scheduled, `githubAuthSunset` on the principal renders a banner under the header for GitHub sessions ("GitHub sign-in to ATK ends on YYYY-MM-DD. Switch to your Emergent account before then." with a **Switch now** button; dismissible per tab). Entra sessions never see it. ### End-user prerequisites -To use ATK Web, a non-technical user needs: +To use ATK Web, a non-technical user needs one of: -1. A **GitHub account** (free signup at github.com). -2. **Membership in the `EmergentSoftware` GitHub organization** — granted by an org admin; one-time. -3. On first visit, **authorize the ATK Web OAuth App** via the standard GitHub consent screen — one click. +1. An **Emergent Software Microsoft account** (tenant member; guests are refused). No consent screen: the app is pre-authorized on the API scope. +2. A **GitHub account** that is an active member of the **`EmergentSoftware`** organization, plus one-time authorization of the ATK Web OAuth App on GitHub's consent screen (until the cutoff). No PATs, no CLI, no terminal, no git knowledge required. @@ -179,17 +176,19 @@ agentic-toolkit-web/ │ ├── routes/ # page components (browse, detail, contribute, bundles) │ ├── lib/ │ │ ├── api/ # GENERATED typed client + types (do not edit) -│ │ ├── api-client.ts # base URL, bearer auth, retries, error mapping -│ │ ├── session.ts # OAuth redirect + code exchange helpers +│ │ ├── api-client.ts # base URL, bearer auth (GitHub token or Entra token getter), retries, error mapping +│ │ ├── entra.ts # MSAL (Entra) client behind the EntraClient interface, plus a fake for tests +│ │ ├── session.ts # session types, GitHub OAuth redirect + code exchange helpers │ │ ├── registry-client.ts # registry index, manifests, READMEs, file listings │ │ ├── download-service.ts # server-built zip / .skill downloads │ │ ├── publish-service.ts # POST /publish and /publish/plan payloads │ │ └── schemas/ # Zod schemas (vendored from agentic-toolkit-cli) │ ├── hooks/ # TanStack Query hooks (useRegistry, useAssetFiles, …) -│ ├── providers/ # SessionProvider (token, GET /me, status machine) +│ ├── providers/ # SessionProvider (Entra + GitHub, GET /me, status machine) │ └── main.tsx ├── public/ ├── .github/workflows/ # validate PRs; build + deploy Pages on main +├── auth-redirect.html # MSAL redirect bridge page (second Vite entry; Entra returns here) ├── vite.config.ts ├── tsconfig.json ├── eslint.config.js diff --git a/auth-redirect.html b/auth-redirect.html new file mode 100644 index 0000000..e2e8459 --- /dev/null +++ b/auth-redirect.html @@ -0,0 +1,12 @@ + + + + + + + Signing in… + + + + + diff --git a/docs/deployment.md b/docs/deployment.md index 7675ee9..b5bebcc 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -27,9 +27,9 @@ Related docs: POST /publish, /publish/plan (open a registry PR) ``` -- The SPA runs entirely in the browser using `HashRouter`; no server-side routing is required. -- The SPA calls `POST /auth/github/exchange` to swap a GitHub OAuth authorization code for an access token. The API holds the OAuth App's client secret (in Key Vault) and never exposes it to the browser. -- Every other call carries `Authorization: Bearer `. The API validates the token and EmergentSoftware org membership and, for publishing, opens the pull request **with that same token** so the PR is authored by the user. +- The SPA runs entirely in the browser using `HashRouter`; no server-side routing is required. `auth-redirect.html` is a second static page: the MSAL redirect bridge that Microsoft Entra returns to after sign-in (see §2.4). +- Two sign-in providers, one session. **Entra** (primary, "Sign in with your Emergent account"): MSAL runs the auth-code + PKCE flow against `login.microsoftonline.com` entirely in the browser; the API is not involved until the first call. **GitHub** (secondary, kept until the GitHub cutoff): the SPA calls `POST /auth/github/exchange` to swap an OAuth authorization code for an access token; the API holds the OAuth App's client secret (in Key Vault) and never exposes it to the browser. +- Every other call carries `Authorization: Bearer `. The API picks the scheme by the token's shape and validates it: Entra access tokens against the Emergent Software tenant (members only, guests refused), GitHub tokens against EmergentSoftware org membership. Publishing with a GitHub token opens the pull request **with that same token** so the PR is authored by the user; publishing with an Entra token uses the API's publish identity, with the commit authored as the user and a "Published by … via Entra" line in the PR body. - The API's OpenAPI contract is vendored at `openapi/openapi.json`; `src/lib/api/` is generated from it (`pnpm refresh-openapi && pnpm generate-api`). --- @@ -67,6 +67,26 @@ On the `EmergentSoftware/agentic-toolkit-web` repo's **Settings → Secrets and The names must match exactly what `deploy-pages.yml` reads and `src/lib/api-client.ts` / `src/lib/session.ts` expect. No repository secrets are required for the SPA deploy. +### 2.4 Entra sign-in (nothing to configure per environment) + +The Emergent-account sign-in uses the `ES ATK Web` app registration in the Emergent Software tenant (client id `07a23158-19c4-4180-a2a9-41cb80882a65`, tenant `25ee13ae-a8a5-4bc2-bb23-aea90536fb0c`, scope `api://8da5aa72-0565-4ae8-bf5a-db89d8bd3186/access_as_user`). One registration serves dev and prod, so the values are constants in `src/lib/entra.ts`; there are no `VITE_*` variables, secrets, or per-environment settings for it. The registration is owned in the monorepo (`infra/README.md`); the web app needs these on it: + +| Setting | Value | +|---|---| +| Platform | Single-page application | +| Redirect URIs (the MSAL redirect bridge, `auth-redirect.html`) | `http://localhost:5173/agentic-toolkit-web/auth-redirect.html`, `https://emergentsoftware.github.io/agentic-toolkit-web/auth-redirect.html` | +| Redirect URIs (post-logout targets, the app root) | `http://localhost:5173/agentic-toolkit-web/`, `https://emergentsoftware.github.io/agentic-toolkit-web/` | +| ID token optional claim | `login_hint` (lets sign-out skip Microsoft's account picker) | +| API permissions | delegated `ATK API / access_as_user` (pre-authorized, no consent prompt) | + +Both URIs include the Vite `base` (`/agentic-toolkit-web/`) because `pnpm dev` serves the app under it too. The bridge page is built as a second Vite entry (`build.rollupOptions.input` in `vite.config.ts`), so it is emitted into `dist/` and published to Pages with the app; it must be served from the app's own origin. A mismatch shows on Microsoft's page as `AADSTS50011` (`redirect_uri_mismatch`). + +Entra tokens are held by MSAL in `sessionStorage` (per tab, like the GitHub token) and refreshed silently; when a silent refresh needs interaction the app signs out with a "Your Emergent sign-in has expired" notice. Sign-out ends the Microsoft web session too (`logoutRedirect`) and lands back on the app root. + +### 2.5 GitHub sign-in cutoff + +The API setting `Atk__Auth__GitHubSunset` (monorepo Terraform `github_auth_sunset`) schedules the end of GitHub sign-in. Before the date, the SPA shows a banner under the header to GitHub sessions ("GitHub sign-in to ATK ends on YYYY-MM-DD…", with a **Switch now** button that starts the Emergent sign-in); after it, GitHub sign-ins are refused with `401 github_auth_retired` and the landing shows the API's message. Entra sessions never see either. Nothing in this repo changes for the cutoff; it is set in the monorepo once the CLI and web both offer the Emergent sign-in. + --- ## 3. Normal deploy flow (`deploy-pages.yml`) @@ -131,10 +151,14 @@ An API-side incident (the exchange, `/me`, or registry reads failing) is handled | Symptom | Likely cause | Check | |---|---|---| +| Emergent sign-in stops on a Microsoft page saying `AADSTS50011` / redirect URI mismatch | The `ES ATK Web` registration lacks the bridge URI for this origin. | Both `auth-redirect.html` URIs in §2.4 must be registered exactly, including `/agentic-toolkit-web/`. | +| Landing shows "Sign-in did not complete (…)" after returning from Microsoft | The user cancelled, or Entra returned an error (`access_denied`, network). | The notice carries Entra's own description; sign in again. | +| Landing shows "Your Emergent sign-in has expired" | A silent token refresh needed interaction (session revoked, 24h SPA refresh-token limit). | Sign in again. | +| App shows "Not authorized" for an Emergent account | `GET /me` returned `403 guest_not_allowed`: the account is a guest in the tenant. | ATK is for tenant members; use an Emergent account or sign in with GitHub. | | Sign-in fails with a CORS error in the browser console | The API's `cors_allowed_origins` does not include the SPA origin. | Must contain `https://emergentsoftware.github.io` (prod) / `http://localhost:5173` (dev) — origin only, no path, no trailing slash. | | Sign-in fails with `Auth exchange failed (HTTP 400): bad_verification_code` | The `code` was already used or expired, or the SPA's client id does not match the API's. | Local dev must use the **dev** OAuth App id (the dev API's id); Pages must use the **prod** id. Start the sign-in over. | | Sign-in fails with `Auth exchange failed (HTTP 500)` | The API is missing its OAuth configuration. | Check `github_oauth_client_id` and the Key Vault secret for that environment. | | App shows "Not authorized" for a known org member | `GET /me` returned `403 org_membership_unverifiable`. | SAML SSO not authorized for the token, or the OAuth App is not approved for the org (`https://github.com/orgs/EmergentSoftware/policies/applications`). The browser console logs the hint. | -| App drops back to the signed-out landing on load | `GET /me` returned `401`; the stored token is dead. | Sign in again. | +| App drops back to the signed-out landing on load | `GET /me` returned `401`; the stored token is dead. | Sign in again. If the landing shows "GitHub sign-in to ATK ended on …", the cutoff has passed: use the Emergent account. | | `VITE_ATK_API_URL is not set` at startup | Repo variable or `.env.local` missing. | Variables must be named `VITE_GITHUB_OAUTH_CLIENT_ID` and `VITE_ATK_API_URL`. | | Downloads fail with "The ATK API is unavailable" | API 5xx (usually GitHub upstream). | Retry; check the API's App Insights in Azure. | diff --git a/openapi/openapi.json b/openapi/openapi.json index 6da73f7..d65bbaa 100644 --- a/openapi/openapi.json +++ b/openapi/openapi.json @@ -3,7 +3,7 @@ "info": { "title": "ATK API", "version": "0.2.0", - "description": "The Agentic Tool Kit API: registry reads, publish, checkout and web sign-in for the ATK CLI and web app. Authenticate with `Authorization: Bearer `; the caller must be an active member of the EmergentSoftware GitHub organisation." + "description": "The Agentic Tool Kit API: registry reads, publish, checkout and web sign-in for the ATK CLI and web app. Authenticate with `Authorization: Bearer ` using either a GitHub token (the caller must be an active member of the EmergentSoftware GitHub organisation) or a Microsoft Entra ID access token for the ATK API (the caller must be a member, not a guest, of the Emergent Software tenant). `GET /me` reports which scheme applied." }, "servers": [ { @@ -125,7 +125,7 @@ }, "/me": { "get": { - "summary": "The authenticated caller as resolved by the API", + "summary": "The authenticated caller as resolved by the API. `github` principals carry `githubAuthSunset` while a GitHub sign-in cutoff is scheduled.", "tags": [ "Auth" ], @@ -141,7 +141,7 @@ } }, "401": { - "description": "Missing or invalid token", + "description": "Missing or invalid token (`unauthenticated`, `unsupported_token`, `invalid_token`), or GitHub sign-in has ended (`github_auth_retired`)", "content": { "application/json": { "schema": { @@ -151,7 +151,17 @@ } }, "403": { - "description": "Not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`)", + "description": "GitHub: not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`). Entra: a guest account (`guest_not_allowed`)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "502": { + "description": "GitHub or Entra could not be reached to validate the token (`github_unavailable`, `entra_unavailable`)", "content": { "application/json": { "schema": { @@ -190,7 +200,7 @@ "description": "Not modified" }, "401": { - "description": "Missing or invalid token", + "description": "Missing or invalid token (`unauthenticated`, `unsupported_token`, `invalid_token`), or GitHub sign-in has ended (`github_auth_retired`)", "content": { "application/json": { "schema": { @@ -200,7 +210,17 @@ } }, "403": { - "description": "Not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`)", + "description": "GitHub: not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`). Entra: a guest account (`guest_not_allowed`)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "502": { + "description": "GitHub or Entra could not be reached to validate the token (`github_unavailable`, `entra_unavailable`)", "content": { "application/json": { "schema": { @@ -281,8 +301,8 @@ } } }, - "404": { - "description": "Unknown asset, version, or file", + "401": { + "description": "Missing or invalid token (`unauthenticated`, `unsupported_token`, `invalid_token`), or GitHub sign-in has ended (`github_auth_retired`)", "content": { "application/json": { "schema": { @@ -291,8 +311,8 @@ } } }, - "401": { - "description": "Missing or invalid token", + "403": { + "description": "GitHub: not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`). Entra: a guest account (`guest_not_allowed`)", "content": { "application/json": { "schema": { @@ -301,8 +321,18 @@ } } }, - "403": { - "description": "Not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`)", + "404": { + "description": "Unknown asset, version, or file", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "502": { + "description": "GitHub or Entra could not be reached to validate the token (`github_unavailable`, `entra_unavailable`)", "content": { "application/json": { "schema": { @@ -383,8 +413,8 @@ } } }, - "404": { - "description": "Unknown asset, version, or file", + "401": { + "description": "Missing or invalid token (`unauthenticated`, `unsupported_token`, `invalid_token`), or GitHub sign-in has ended (`github_auth_retired`)", "content": { "application/json": { "schema": { @@ -393,8 +423,8 @@ } } }, - "401": { - "description": "Missing or invalid token", + "403": { + "description": "GitHub: not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`). Entra: a guest account (`guest_not_allowed`)", "content": { "application/json": { "schema": { @@ -403,8 +433,18 @@ } } }, - "403": { - "description": "Not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`)", + "404": { + "description": "Unknown asset, version, or file", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "502": { + "description": "GitHub or Entra could not be reached to validate the token (`github_unavailable`, `entra_unavailable`)", "content": { "application/json": { "schema": { @@ -485,8 +525,8 @@ } } }, - "404": { - "description": "Unknown asset, version, or file", + "401": { + "description": "Missing or invalid token (`unauthenticated`, `unsupported_token`, `invalid_token`), or GitHub sign-in has ended (`github_auth_retired`)", "content": { "application/json": { "schema": { @@ -495,8 +535,8 @@ } } }, - "401": { - "description": "Missing or invalid token", + "403": { + "description": "GitHub: not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`). Entra: a guest account (`guest_not_allowed`)", "content": { "application/json": { "schema": { @@ -505,8 +545,18 @@ } } }, - "403": { - "description": "Not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`)", + "404": { + "description": "Unknown asset, version, or file", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "502": { + "description": "GitHub or Entra could not be reached to validate the token (`github_unavailable`, `entra_unavailable`)", "content": { "application/json": { "schema": { @@ -597,8 +647,8 @@ } } }, - "404": { - "description": "Unknown asset, version, or file", + "401": { + "description": "Missing or invalid token (`unauthenticated`, `unsupported_token`, `invalid_token`), or GitHub sign-in has ended (`github_auth_retired`)", "content": { "application/json": { "schema": { @@ -607,8 +657,8 @@ } } }, - "401": { - "description": "Missing or invalid token", + "403": { + "description": "GitHub: not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`). Entra: a guest account (`guest_not_allowed`)", "content": { "application/json": { "schema": { @@ -617,8 +667,18 @@ } } }, - "403": { - "description": "Not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`)", + "404": { + "description": "Unknown asset, version, or file", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "502": { + "description": "GitHub or Entra could not be reached to validate the token (`github_unavailable`, `entra_unavailable`)", "content": { "application/json": { "schema": { @@ -713,8 +773,8 @@ } } }, - "404": { - "description": "Unknown asset, version, or file", + "401": { + "description": "Missing or invalid token (`unauthenticated`, `unsupported_token`, `invalid_token`), or GitHub sign-in has ended (`github_auth_retired`)", "content": { "application/json": { "schema": { @@ -723,8 +783,8 @@ } } }, - "401": { - "description": "Missing or invalid token", + "403": { + "description": "GitHub: not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`). Entra: a guest account (`guest_not_allowed`)", "content": { "application/json": { "schema": { @@ -733,8 +793,18 @@ } } }, - "403": { - "description": "Not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`)", + "404": { + "description": "Unknown asset, version, or file", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "502": { + "description": "GitHub or Entra could not be reached to validate the token (`github_unavailable`, `entra_unavailable`)", "content": { "application/json": { "schema": { @@ -798,8 +868,8 @@ } } }, - "404": { - "description": "Unknown asset, version, or file", + "401": { + "description": "Missing or invalid token (`unauthenticated`, `unsupported_token`, `invalid_token`), or GitHub sign-in has ended (`github_auth_retired`)", "content": { "application/json": { "schema": { @@ -808,8 +878,8 @@ } } }, - "401": { - "description": "Missing or invalid token", + "403": { + "description": "GitHub: not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`). Entra: a guest account (`guest_not_allowed`)", "content": { "application/json": { "schema": { @@ -818,8 +888,18 @@ } } }, - "403": { - "description": "Not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`)", + "404": { + "description": "Unknown asset, version, or file", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "502": { + "description": "GitHub or Entra could not be reached to validate the token (`github_unavailable`, `entra_unavailable`)", "content": { "application/json": { "schema": { @@ -883,8 +963,8 @@ } } }, - "404": { - "description": "Unknown asset, version, or file", + "401": { + "description": "Missing or invalid token (`unauthenticated`, `unsupported_token`, `invalid_token`), or GitHub sign-in has ended (`github_auth_retired`)", "content": { "application/json": { "schema": { @@ -893,8 +973,8 @@ } } }, - "401": { - "description": "Missing or invalid token", + "403": { + "description": "GitHub: not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`). Entra: a guest account (`guest_not_allowed`)", "content": { "application/json": { "schema": { @@ -903,8 +983,18 @@ } } }, - "403": { - "description": "Not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`)", + "404": { + "description": "Unknown asset, version, or file", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "502": { + "description": "GitHub or Entra could not be reached to validate the token (`github_unavailable`, `entra_unavailable`)", "content": { "application/json": { "schema": { @@ -924,7 +1014,7 @@ }, "/bundles/{name}/{version}/download": { "get": { - "summary": "Zip of the bundle: `bundle.json` at the root and each member under `{member}/…`. `format=skill` drops `bundle.json` and nests skill members as `{member}.skill`.", + "summary": "Zip of the bundle: `bundle.json` (plus the bundle's `README.md` when it has one) at the root and each member under `{member}/…`. `format=skill` drops `bundle.json` and `README.md` and nests skill members as `{member}.skill`.", "tags": [ "Bundles" ], @@ -982,8 +1072,8 @@ } } }, - "404": { - "description": "Unknown asset, version, or file", + "401": { + "description": "Missing or invalid token (`unauthenticated`, `unsupported_token`, `invalid_token`), or GitHub sign-in has ended (`github_auth_retired`)", "content": { "application/json": { "schema": { @@ -992,8 +1082,8 @@ } } }, - "401": { - "description": "Missing or invalid token", + "403": { + "description": "GitHub: not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`). Entra: a guest account (`guest_not_allowed`)", "content": { "application/json": { "schema": { @@ -1002,8 +1092,18 @@ } } }, - "403": { - "description": "Not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`)", + "404": { + "description": "Unknown asset, version, or file", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "502": { + "description": "GitHub or Entra could not be reached to validate the token (`github_unavailable`, `entra_unavailable`)", "content": { "application/json": { "schema": { @@ -1094,8 +1194,8 @@ } } }, - "404": { - "description": "Unknown asset, version, or file", + "401": { + "description": "Missing or invalid token (`unauthenticated`, `unsupported_token`, `invalid_token`), or GitHub sign-in has ended (`github_auth_retired`)", "content": { "application/json": { "schema": { @@ -1104,8 +1204,8 @@ } } }, - "401": { - "description": "Missing or invalid token", + "403": { + "description": "GitHub: not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`). Entra: a guest account (`guest_not_allowed`)", "content": { "application/json": { "schema": { @@ -1114,8 +1214,18 @@ } } }, - "403": { - "description": "Not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`)", + "404": { + "description": "Unknown asset, version, or file", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "502": { + "description": "GitHub or Entra could not be reached to validate the token (`github_unavailable`, `entra_unavailable`)", "content": { "application/json": { "schema": { @@ -1170,6 +1280,26 @@ } } }, + "401": { + "description": "Missing or invalid token (`unauthenticated`, `unsupported_token`, `invalid_token`), or GitHub sign-in has ended (`github_auth_retired`)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "403": { + "description": "GitHub: not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`). Entra: a guest account (`guest_not_allowed`)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, "409": { "description": "`version_not_bumped`, `version_exists`, or `branch_exists`", "content": { @@ -1180,8 +1310,8 @@ } } }, - "401": { - "description": "Missing or invalid token", + "500": { + "description": "Entra caller but the API's GitHub publish token is missing or rejected (`server_misconfigured`)", "content": { "application/json": { "schema": { @@ -1190,8 +1320,8 @@ } } }, - "403": { - "description": "Not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`)", + "502": { + "description": "GitHub unreachable (`github_unavailable`, `registry_unavailable`)", "content": { "application/json": { "schema": { @@ -1211,7 +1341,7 @@ }, "/publish": { "post": { - "summary": "Validate, then create the branch, commit and pull request on the registry with the caller's own token so the PR is authored by them. Global targets get the default reviewers; org targets use `reviewers`.", + "summary": "Validate, then create the branch, commit and pull request on the registry. GitHub callers write with their own token so the PR is authored by them; Entra callers are written by the API's publish identity with the commit authored as them, and every PR body names the publisher. Global targets get the default reviewers; org targets use `reviewers`.", "tags": [ "Publish" ], @@ -1246,6 +1376,26 @@ } } }, + "401": { + "description": "Missing or invalid token (`unauthenticated`, `unsupported_token`, `invalid_token`), or GitHub sign-in has ended (`github_auth_retired`)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "403": { + "description": "GitHub: not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`). Entra: a guest account (`guest_not_allowed`)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, "409": { "description": "`version_not_bumped`, `version_exists`, or `branch_exists`", "content": { @@ -1256,8 +1406,8 @@ } } }, - "401": { - "description": "Missing or invalid token", + "500": { + "description": "Entra caller but the API's GitHub publish token is missing or rejected (`server_misconfigured`)", "content": { "application/json": { "schema": { @@ -1266,8 +1416,8 @@ } } }, - "403": { - "description": "Not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`)", + "502": { + "description": "GitHub unreachable (`github_unavailable`, `registry_unavailable`)", "content": { "application/json": { "schema": { @@ -1291,7 +1441,7 @@ "githubToken": { "type": "http", "scheme": "bearer", - "description": "A GitHub token (OAuth, `gh auth token`, or PAT) with `read:org`, and `repo` for publishing." + "description": "Either a GitHub token (OAuth, `gh auth token`, or PAT) with `read:org` (and `repo` for publishing), or a Microsoft Entra ID v2.0 access token for the ATK API carrying the `access_as_user` scope (`az account get-access-token --scope api:///access_as_user`). While a GitHub sign-in cutoff is scheduled, responses to GitHub-authenticated requests carry `Sunset` and `Deprecation: true` headers; after it GitHub tokens get `401 github_auth_retired`." } }, "schemas": { @@ -1558,11 +1708,15 @@ "type": "object", "properties": { "scheme": { - "description": "Auth scheme that validated the token (`github`)", + "description": "Auth scheme that validated the token: `github` or `entra`", + "type": "string" + }, + "id": { + "description": "Stable user id: the GitHub numeric user id, or the Entra object id (`oid`)", "type": "string" }, "login": { - "description": "GitHub login", + "description": "GitHub login, or the Entra user principal name (e.g. `jasonp@emergentsoftware.net`)", "type": "string" }, "name": { @@ -1572,16 +1726,33 @@ "null" ] }, + "email": { + "description": "Email: the GitHub public profile email (often absent), or the Entra `email` claim falling back to the UPN", + "type": [ + "string", + "null" + ] + }, "avatarUrl": { - "description": "Avatar URL", + "description": "Avatar URL (GitHub only)", "type": [ "string", "null" ] + }, + "githubAuthSunset": { + "description": "Only on `github` principals while a GitHub sign-in cutoff is scheduled: when GitHub sign-in to ATK ends (RFC 3339, UTC). Absent otherwise. The same responses carry `Sunset` and `Deprecation` headers.", + "type": [ + "string", + "null" + ], + "format": "date-time", + "default": null } }, "required": [ "scheme", + "id", "login" ] }, diff --git a/package.json b/package.json index 089ada0..bce6702 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "generate-api": "openapi-ts" }, "dependencies": { + "@azure/msal-browser": "5.21.0", "@base-ui-components/react": "^1.0.0-beta.0", "@tanstack/react-form": "^1.29.0", "@tanstack/react-query": "^5.59.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c5f948f..69fe543 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@azure/msal-browser': + specifier: 5.21.0 + version: 5.21.0 '@base-ui-components/react': specifier: ^1.0.0-beta.0 version: 1.0.0-rc.0(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -157,6 +160,14 @@ packages: '@asamuzakjp/nwsapi@2.3.9': resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@azure/msal-browser@5.21.0': + resolution: {integrity: sha512-80OcuXDErmcEDAIH9pBtSqBsed2sPT/IWmbG3xHLoPMl5zc8TINd6SlJAbVSmN5huGa3xGAg5qR7VnpaIEK0Zw==} + engines: {node: '>=0.8.0'} + + '@azure/msal-common@16.14.0': + resolution: {integrity: sha512-A4rb55hI86Q9tBl/+jBj7TMz7iX2RFgQs/nExFzcAtoI/BFRVdaH5SL/MivrYD7qvweMpN8AgVvVMHV8UBYxew==} + engines: {node: '>=0.8.0'} + '@babel/code-frame@7.29.0': resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} @@ -3108,6 +3119,12 @@ snapshots: '@asamuzakjp/nwsapi@2.3.9': {} + '@azure/msal-browser@5.21.0': + dependencies: + '@azure/msal-common': 16.14.0 + + '@azure/msal-common@16.14.0': {} + '@babel/code-frame@7.29.0': dependencies: '@babel/helper-validator-identifier': 7.28.5 diff --git a/src/App.tsx b/src/App.tsx index d4ada82..f4a1a7b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,12 +1,13 @@ import { Toast } from '@base-ui-components/react/toast'; import { QueryClientProvider } from '@tanstack/react-query'; -import { type ReactNode } from 'react'; +import { type ReactNode, useState } from 'react'; import { Route, Routes } from 'react-router'; import { ErrorBoundary } from '@/components/ErrorBoundary'; import { AppLayout } from '@/components/layout/AppLayout'; import { RequireAuth } from '@/components/RequireAuth'; import { ThemeProvider } from '@/components/theme/ThemeProvider'; +import { createEntraClient, type EntraClient } from '@/lib/entra'; import { queryClient } from '@/lib/query-client'; import { SessionProvider } from '@/providers/SessionProvider'; import { AssetDetailRoute } from '@/routes/AssetDetail'; @@ -22,11 +23,17 @@ import { NotAuthorizedRoute } from '@/routes/NotAuthorized'; import { NotFoundRoute } from '@/routes/NotFound'; import { SignInRoute } from '@/routes/SignIn'; -export function App() { +interface AppProps { + /** Override the Entra (MSAL) client; tests pass a fake. */ + entraClient?: EntraClient; +} + +export function App({ entraClient }: AppProps = {}) { + const [resolvedEntraClient] = useState(() => entraClient ?? createEntraClient()); return ( - + diff --git a/src/__tests__/App.test.tsx b/src/__tests__/App.test.tsx index ccb0c52..59475ef 100644 --- a/src/__tests__/App.test.tsx +++ b/src/__tests__/App.test.tsx @@ -1,10 +1,22 @@ import { render, screen } from '@testing-library/react'; +import { NuqsTestingAdapter } from 'nuqs/adapters/testing'; import { MemoryRouter } from 'react-router'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { App } from '@/App'; +import { createFakeEntraClient } from '@/lib/entra'; import { SESSION_STORAGE_KEYS } from '@/lib/session'; +function renderApp(path: string) { + return render( + + + + + , + ); +} + describe('App', () => { beforeEach(() => { window.sessionStorage.clear(); @@ -14,36 +26,25 @@ describe('App', () => { window.sessionStorage.clear(); }); - it('renders the signed-out landing at `/` when no session exists', () => { - render( - - - , - ); + it('renders the signed-out landing at `/` when no session exists', async () => { + renderApp('/'); - expect(screen.getByTestId('signed-out-landing')).toBeInTheDocument(); + expect(await screen.findByTestId('signed-out-landing')).toBeInTheDocument(); expect(screen.getByRole('heading', { level: 1, name: /agentic toolkit/i })).toBeInTheDocument(); - expect(screen.getByTestId('landing-sign-in')).toBeInTheDocument(); + expect(screen.getByTestId('landing-sign-in')).toHaveTextContent(/emergent account/i); + expect(screen.getByTestId('landing-sign-in-github')).toHaveTextContent(/github/i); }); it('renders the NotFound catch-all for unknown paths', () => { - render( - - - , - ); + renderApp('/totally/unknown/path'); expect(screen.getByRole('heading', { level: 1, name: /page not found/i })).toBeInTheDocument(); }); - it('redirects protected routes to `/` when signed out', () => { + it('redirects protected routes to `/` when signed out', async () => { window.sessionStorage.removeItem(SESSION_STORAGE_KEYS.token); - render( - - - , - ); + renderApp('/bundles'); - expect(screen.getByTestId('signed-out-landing')).toBeInTheDocument(); + expect(await screen.findByTestId('signed-out-landing')).toBeInTheDocument(); }); }); diff --git a/src/__tests__/components/Header.test.tsx b/src/__tests__/components/Header.test.tsx new file mode 100644 index 0000000..0c30a38 --- /dev/null +++ b/src/__tests__/components/Header.test.tsx @@ -0,0 +1,61 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router'; +import { describe, expect, it, vi } from 'vitest'; + +import { Header } from '@/components/layout/Header'; +import { ThemeProvider } from '@/components/theme/ThemeProvider'; +import { type SessionContextValue } from '@/providers/SessionProvider'; + +import { makeSessionValue, SessionHarness } from '../utils/session-harness'; + +function renderHeader(session: SessionContextValue) { + return render( + + + +
+ + + , + ); +} + +describe('Header', () => { + it('starts an Entra sign-in from the Sign in button', () => { + const signIn = vi.fn(); + renderHeader(makeSessionValue({ signIn })); + + fireEvent.click(screen.getByRole('button', { name: 'Sign in' })); + expect(signIn).toHaveBeenCalledWith('entra', undefined); + }); + + it('shows the Entra UPN and a Sign out button for an Entra session', () => { + const signOut = vi.fn(); + renderHeader( + makeSessionValue({ + scheme: 'entra', + signOut, + status: 'member', + user: { id: 'oid', login: 'jasonp@emergentsoftware.net', name: 'Jason Paff', scheme: 'entra' }, + }), + ); + + expect(screen.getByTestId('user-login')).toHaveTextContent('jasonp@emergentsoftware.net'); + expect(screen.queryByRole('img', { name: /avatar/i })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Sign out' })); + expect(signOut).toHaveBeenCalledTimes(1); + }); + + it('shows the GitHub login for a GitHub session', () => { + renderHeader( + makeSessionValue({ + scheme: 'github', + status: 'member', + user: { id: '1', login: 'octo', name: null, scheme: 'github' }, + }), + ); + + expect(screen.getByTestId('user-login')).toHaveTextContent('octo'); + expect(screen.getByRole('button', { name: 'Sign out' })).toBeInTheDocument(); + }); +}); diff --git a/src/__tests__/components/RequireAuth.test.tsx b/src/__tests__/components/RequireAuth.test.tsx index e4b95ca..1a9cb1a 100644 --- a/src/__tests__/components/RequireAuth.test.tsx +++ b/src/__tests__/components/RequireAuth.test.tsx @@ -45,12 +45,12 @@ describe('RequireAuth', () => { afterEach(() => window.sessionStorage.clear()); it('renders children when the session status is "member"', () => { - renderAt('/protected', makeSessionValue({ status: 'member', token: 'tok' })); + renderAt('/protected', makeSessionValue({ status: 'member' })); expect(screen.getByTestId('protected-child')).toBeInTheDocument(); }); it('shows a loading screen while verifying', () => { - renderAt('/protected', makeSessionValue({ status: 'verifying', token: 'tok' })); + renderAt('/protected', makeSessionValue({ status: 'verifying' })); expect(screen.getByTestId('require-auth-loading')).toBeInTheDocument(); expect(screen.queryByTestId('protected-child')).not.toBeInTheDocument(); }); @@ -67,7 +67,7 @@ describe('RequireAuth', () => { }); it('redirects non-members to /not-authorized', () => { - renderAt('/protected', makeSessionValue({ status: 'non-member', token: 'tok' })); + renderAt('/protected', makeSessionValue({ status: 'non-member' })); expect(screen.getByTestId('not-authorized')).toBeInTheDocument(); expect(screen.queryByTestId('protected-child')).not.toBeInTheDocument(); }); diff --git a/src/__tests__/components/SignedOutLanding.test.tsx b/src/__tests__/components/SignedOutLanding.test.tsx new file mode 100644 index 0000000..fcb9ad2 --- /dev/null +++ b/src/__tests__/components/SignedOutLanding.test.tsx @@ -0,0 +1,78 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { SignedOutLanding } from '@/components/SignedOutLanding'; +import { SESSION_STORAGE_KEYS } from '@/lib/session'; + +import { makeSessionValue, SessionHarness } from '../utils/session-harness'; + +describe('SignedOutLanding', () => { + beforeEach(() => window.sessionStorage.clear()); + afterEach(() => window.sessionStorage.clear()); + + it('offers the Emergent account as the primary sign-in and GitHub as the secondary', () => { + const signIn = vi.fn(); + render( + + + , + ); + + expect(screen.getByTestId('landing-sign-in')).toHaveTextContent('Sign in with your Emergent account'); + expect(screen.getByTestId('landing-sign-in-github')).toHaveTextContent('Sign in with GitHub'); + expect(screen.getByText(/Emergent Software staff and members of the/)).toBeInTheDocument(); + + fireEvent.click(screen.getByTestId('landing-sign-in')); + expect(signIn).toHaveBeenLastCalledWith('entra', undefined); + + fireEvent.click(screen.getByTestId('landing-sign-in-github')); + expect(signIn).toHaveBeenLastCalledWith('github', undefined); + }); + + it('passes the stashed return path to whichever provider is clicked', () => { + window.sessionStorage.setItem(SESSION_STORAGE_KEYS.pendingReturn, '/bundles'); + const signIn = vi.fn(); + render( + + + , + ); + + fireEvent.click(screen.getByTestId('landing-sign-in-github')); + expect(signIn).toHaveBeenCalledWith('github', '/bundles'); + expect(window.sessionStorage.getItem(SESSION_STORAGE_KEYS.pendingReturn)).toBeNull(); + }); + + it('shows the session notice above the buttons and lets the user dismiss it', () => { + const dismissNotice = vi.fn(); + render( + + + , + ); + + const notice = screen.getByTestId('session-notice'); + expect(notice).toHaveTextContent('GitHub sign-in to ATK ended on 2026-09-14.'); + expect(notice).toHaveAttribute('data-kind', 'github_auth_retired'); + expect( + notice.compareDocumentPosition(screen.getByTestId('landing-sign-in')) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + + fireEvent.click(screen.getByRole('button', { name: /dismiss notice/i })); + expect(dismissNotice).toHaveBeenCalledTimes(1); + }); + + it('renders no notice when there is none', () => { + render( + + + , + ); + expect(screen.queryByTestId('session-notice')).not.toBeInTheDocument(); + }); +}); diff --git a/src/__tests__/components/SunsetBanner.test.tsx b/src/__tests__/components/SunsetBanner.test.tsx new file mode 100644 index 0000000..7700df5 --- /dev/null +++ b/src/__tests__/components/SunsetBanner.test.tsx @@ -0,0 +1,78 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { SunsetBanner } from '@/components/layout/SunsetBanner'; +import { SESSION_STORAGE_KEYS } from '@/lib/session'; +import { type SessionContextValue } from '@/providers/SessionProvider'; + +import { makeSessionValue, SessionHarness } from '../utils/session-harness'; + +const SUNSET = '2027-01-31T00:00:00+00:00'; + +function githubSession(overrides: Partial = {}) { + return makeSessionValue({ + scheme: 'github', + status: 'member', + user: { githubAuthSunset: SUNSET, id: '1', login: 'octo', scheme: 'github' }, + ...overrides, + }); +} + +function renderBanner(session: SessionContextValue) { + return render( + + + , + ); +} + +describe('SunsetBanner', () => { + beforeEach(() => window.sessionStorage.clear()); + afterEach(() => window.sessionStorage.clear()); + + it('shows the cutoff date and a Switch now button for a GitHub session with githubAuthSunset', () => { + const signIn = vi.fn(); + renderBanner(githubSession({ signIn })); + + const banner = screen.getByTestId('sunset-banner'); + expect(banner).toHaveTextContent( + 'GitHub sign-in to ATK ends on 2027-01-31. Switch to your Emergent account before then.', + ); + + fireEvent.click(screen.getByTestId('sunset-switch')); + expect(signIn).toHaveBeenCalledWith('entra'); + }); + + it('is absent for a GitHub session without a sunset', () => { + renderBanner(githubSession({ user: { id: '1', login: 'octo', scheme: 'github' } })); + expect(screen.queryByTestId('sunset-banner')).not.toBeInTheDocument(); + }); + + it('is never shown for an Entra session', () => { + renderBanner( + makeSessionValue({ + scheme: 'entra', + status: 'member', + user: { githubAuthSunset: SUNSET, id: 'oid', login: 'jasonp@emergentsoftware.net', scheme: 'entra' }, + }), + ); + expect(screen.queryByTestId('sunset-banner')).not.toBeInTheDocument(); + }); + + it('is absent when signed out', () => { + renderBanner(makeSessionValue()); + expect(screen.queryByTestId('sunset-banner')).not.toBeInTheDocument(); + }); + + it('dismiss hides it and persists for the tab', () => { + const { unmount } = renderBanner(githubSession()); + fireEvent.click(screen.getByTestId('sunset-dismiss')); + + expect(screen.queryByTestId('sunset-banner')).not.toBeInTheDocument(); + expect(window.sessionStorage.getItem(SESSION_STORAGE_KEYS.sunsetDismissed)).toBe('1'); + + unmount(); + renderBanner(githubSession()); + expect(screen.queryByTestId('sunset-banner')).not.toBeInTheDocument(); + }); +}); diff --git a/src/__tests__/lib/api-client.test.ts b/src/__tests__/lib/api-client.test.ts index 63a45c9..d0dfd7c 100644 --- a/src/__tests__/lib/api-client.test.ts +++ b/src/__tests__/lib/api-client.test.ts @@ -1,7 +1,16 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { getRegistry, me } from '@/lib/api'; -import { ApiRequestError, createApiClient, getApiUrl, isApiError, unwrap } from '@/lib/api-client'; +import { + ApiRequestError, + createApiClient, + getApiUrl, + isApiError, + NOT_MEMBER_CODES, + SESSION_EXPIRED_CODE, + sessionExpiredError, + unwrap, +} from '@/lib/api-client'; import { apiErrorResponse, jsonResponse, makeTestApiClient, stubFetch, textResponse } from '../utils/api-stub'; @@ -35,7 +44,7 @@ describe('createApiClient', () => { expect(calls[0]!.headers.get('authorization')).toBe('Bearer gho_abc'); }); - it('omits the Authorization header when built without a token', async () => { + it('omits the Authorization header when built without a credential', async () => { const { calls } = stubFetch(() => jsonResponse({})); await getRegistry({ client: createApiClient(null, { retry: { maxRetries: 0 } }) }); @@ -43,6 +52,38 @@ describe('createApiClient', () => { expect(calls[0]!.headers.get('authorization')).toBeNull(); }); + it('asks an Entra credential for a token on every request', async () => { + const { calls } = stubFetch(() => jsonResponse({ login: 'jasonp@emergentsoftware.net', scheme: 'entra' })); + const getToken = vi.fn(async () => `eyJ.token.${getToken.mock.calls.length}`); + const client = createApiClient({ getToken, scheme: 'entra' }, { retry: { maxRetries: 0 } }); + + await me({ client }); + await me({ client }); + + expect(getToken).toHaveBeenCalledTimes(2); + expect(calls[0]!.headers.get('authorization')).toBe('Bearer eyJ.token.1'); + expect(calls[1]!.headers.get('authorization')).toBe('Bearer eyJ.token.2'); + }); + + it('surfaces a throwing Entra token getter as the 401 it carries, without sending the request', async () => { + const { calls } = stubFetch(() => jsonResponse({})); + const client = createApiClient({ getToken: async () => Promise.reject(sessionExpiredError()), scheme: 'entra' }); + + const result = await me({ client }); + + expect(calls).toHaveLength(0); + expect(result.response).toBeUndefined(); + const err = catchError(() => unwrap(result, 'your account')); + expect(isApiError(err, SESSION_EXPIRED_CODE, 401)).toBe(true); + expect(err.message).toBe('Your Emergent sign-in has expired. Sign in again.'); + }); + + it('counts guest_not_allowed as a not-member code', () => { + expect(NOT_MEMBER_CODES.has('guest_not_allowed')).toBe(true); + expect(NOT_MEMBER_CODES.has('not_org_member')).toBe(true); + expect(NOT_MEMBER_CODES.has('org_membership_unverifiable')).toBe(true); + }); + it('retries transient failures through fetchWithRetry', async () => { const fixture = { assets: [], version: 'x' }; const { calls } = stubFetch((_req, index) => (index === 0 ? textResponse('', 503) : jsonResponse(fixture))); @@ -102,9 +143,12 @@ describe('unwrap', () => { const at = (status: number, error?: unknown) => catchError(() => unwrap({ error: error ?? {}, response: new Response(null, { status }) }, 'the registry index')); - expect(at(401).message).toMatch(/sign in again/i); + expect(at(401).message).toBe('Your session is no longer valid. Sign in again.'); + expect(at(401, { error: 'invalid_token', message: 'Bad credentials' }).message).not.toMatch(/GitHub/); expect(at(403, { error: 'not_org_member', message: 'nope' }).message).toMatch(/EmergentSoftware/); expect(at(403, { error: 'not_org_member', message: 'nope' }).code).toBe('not_org_member'); + expect(at(403, { error: 'guest_not_allowed', message: 'nope' }).message).toMatch(/guests/i); + expect(at(403, { error: 'guest_not_allowed', message: 'nope' }).code).toBe('guest_not_allowed'); expect(at(404).message).toMatch(/was not found/i); expect(at(429).message).toMatch(/rate limiting/i); expect(at(503, { error: 'upstream_unavailable', message: 'GitHub timed out' }).message).toMatch( @@ -112,6 +156,19 @@ describe('unwrap', () => { ); }); + it('keeps the API message verbatim for 401 github_auth_retired', () => { + const message = + 'GitHub sign-in to ATK ended on 2026-09-14. Run `atk login`, or sign in with your Emergent account.'; + const err = catchError(() => + unwrap( + { error: { error: 'github_auth_retired', message }, response: new Response(null, { status: 401 }) }, + 'your account', + ), + ); + expect(isApiError(err, 'github_auth_retired', 401)).toBe(true); + expect(err.message).toBe(message); + }); + it('falls back to http_error with the raw body when there is no envelope', () => { const err = catchError(() => unwrap({ error: 'Bad Gateway', response: new Response(null, { status: 502 }) }, 'the registry index'), diff --git a/src/__tests__/lib/entra.test.ts b/src/__tests__/lib/entra.test.ts new file mode 100644 index 0000000..81c7510 --- /dev/null +++ b/src/__tests__/lib/entra.test.ts @@ -0,0 +1,128 @@ +import { + BrowserAuthError, + BrowserAuthErrorCodes, + ClientAuthError, + ClientAuthErrorCodes, + InteractionRequiredAuthError, + ServerError, +} from '@azure/msal-browser'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { + appRootUrl, + createFakeEntraClient, + describeEntraError, + ENTRA_AUTHORITY, + ENTRA_CLIENT_ID, + ENTRA_SCOPES, + ENTRA_TENANT_ID, + isSessionExpiredError, + redirectBridgeUrl, + scrubRedirectState, +} from '@/lib/entra'; + +describe('entra constants', () => { + it('targets the Emergent Software tenant, the ES ATK Web SPA client and the ATK API scope', () => { + expect(ENTRA_TENANT_ID).toBe('25ee13ae-a8a5-4bc2-bb23-aea90536fb0c'); + expect(ENTRA_AUTHORITY).toBe(`https://login.microsoftonline.com/${ENTRA_TENANT_ID}`); + expect(ENTRA_CLIENT_ID).toBe('07a23158-19c4-4180-a2a9-41cb80882a65'); + expect(ENTRA_SCOPES).toEqual(['api://8da5aa72-0565-4ae8-bf5a-db89d8bd3186/access_as_user']); + }); + + it('derives the redirect URIs from the page origin and the Vite base', () => { + // vitest serves BASE_URL as '/'; the build uses '/agentic-toolkit-web/'. + expect(appRootUrl()).toBe(`${window.location.origin}/`); + expect(redirectBridgeUrl()).toBe(`${window.location.origin}/auth-redirect.html`); + }); +}); + +describe('scrubRedirectState', () => { + afterEach(() => window.history.replaceState({}, '', '/')); + + it('removes the state MSAL appends to the post-logout URL and keeps the rest', () => { + window.history.replaceState({}, '', '/agentic-toolkit-web/?state=abc&keep=1#/bundles'); + scrubRedirectState(); + expect(window.location.pathname).toBe('/agentic-toolkit-web/'); + expect(window.location.search).toBe('?keep=1'); + expect(window.location.hash).toBe('#/bundles'); + }); + + it('leaves a URL without state untouched', () => { + window.history.replaceState({}, '', '/agentic-toolkit-web/#/'); + scrubRedirectState(); + expect(window.location.href).toBe(`${window.location.origin}/agentic-toolkit-web/#/`); + }); +}); + +describe('isSessionExpiredError', () => { + it('treats interaction-required, invalid_grant and no-account failures as an expired session', () => { + expect(isSessionExpiredError(new InteractionRequiredAuthError('login_required', 'c'))).toBe(true); + expect(isSessionExpiredError(new InteractionRequiredAuthError('refresh_token_expired', 'c'))).toBe(true); + expect(isSessionExpiredError(new ServerError('invalid_grant', 'c'))).toBe(true); + expect(isSessionExpiredError(new BrowserAuthError(BrowserAuthErrorCodes.noAccountError, 'c'))).toBe(true); + expect(isSessionExpiredError(new ClientAuthError(ClientAuthErrorCodes.noAccountFound, 'c'))).toBe(true); + }); + + it('treats transient failures as not expired', () => { + expect(isSessionExpiredError(new BrowserAuthError(BrowserAuthErrorCodes.noNetworkConnectivity, 'c'))).toBe(false); + expect(isSessionExpiredError(new BrowserAuthError(BrowserAuthErrorCodes.interactionInProgress, 'c'))).toBe(false); + expect(isSessionExpiredError(new ClientAuthError(ClientAuthErrorCodes.networkError, 'c'))).toBe(false); + expect(isSessionExpiredError(new ServerError('server_error', 'c'))).toBe(false); + expect(isSessionExpiredError(new Error('boom'))).toBe(false); + expect(isSessionExpiredError(undefined)).toBe(false); + }); +}); + +describe('describeEntraError', () => { + it("keeps Entra's own description for server errors", () => { + const error = new ServerError('access_denied', 'c', 'AADSTS65004: User declined to consent.'); + expect(describeEntraError(error)).toBe( + 'Sign-in did not complete (access_denied): AADSTS65004: User declined to consent.', + ); + }); + + it('falls back to the code when MSAL only carries a hashed message pointer', () => { + const error = new BrowserAuthError(BrowserAuthErrorCodes.userCancelled, 'c'); + expect(describeEntraError(error)).toBe('Sign-in did not complete (user_cancelled).'); + }); + + it('describes plain errors and unknown values', () => { + expect(describeEntraError(new Error('offline'))).toBe('Sign-in did not complete: offline'); + expect(describeEntraError('?')).toBe('Sign-in did not complete.'); + }); +}); + +describe('createFakeEntraClient', () => { + const account = { homeAccountId: 'home-1', name: 'Jason Paff', username: 'jasonp@emergentsoftware.net' }; + + it('installs the redirect result as the account and records calls', async () => { + const client = createFakeEntraClient({ redirectResult: account }); + await client.initialize(); + expect(client.getAccount()).toBeNull(); + await expect(client.handleRedirect()).resolves.toEqual(account); + expect(client.getAccount()).toEqual(account); + await expect(client.acquireToken()).resolves.toBe('entra-access-token'); + await client.login('/bundles'); + expect(client.calls).toEqual([ + { method: 'initialize' }, + { method: 'handleRedirect' }, + { method: 'acquireToken' }, + { method: 'login', returnPath: '/bundles' }, + ]); + }); + + it('rejects from the scripted errors and forgets the account on logout and clearAccount', async () => { + const client = createFakeEntraClient({ + account, + redirectError: new Error('cancelled'), + tokenError: new Error('x'), + }); + await expect(client.handleRedirect()).rejects.toThrow('cancelled'); + await expect(client.acquireToken()).rejects.toThrow('x'); + await client.clearAccount(); + expect(client.getAccount()).toBeNull(); + client.state.account = account; + await client.logout(); + expect(client.getAccount()).toBeNull(); + }); +}); diff --git a/src/__tests__/lib/session.test.ts b/src/__tests__/lib/session.test.ts new file mode 100644 index 0000000..eb499a2 --- /dev/null +++ b/src/__tests__/lib/session.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; + +import { defaultAuthorFor, formatSunsetDate } from '@/lib/session'; + +describe('defaultAuthorFor', () => { + it('uses the GitHub login for a GitHub session', () => { + expect(defaultAuthorFor('github', { id: '1', login: 'octo', name: 'Octo Cat', scheme: 'github' })).toBe('octo'); + }); + + it('uses the display name for an Entra session, falling back to the UPN', () => { + const upn = 'jasonp@emergentsoftware.net'; + expect(defaultAuthorFor('entra', { id: 'oid', login: upn, name: 'Jason Paff', scheme: 'entra' })).toBe( + 'Jason Paff', + ); + expect(defaultAuthorFor('entra', { id: 'oid', login: upn, name: null, scheme: 'entra' })).toBe(upn); + expect(defaultAuthorFor('entra', { id: 'oid', login: upn, name: '', scheme: 'entra' })).toBe(upn); + }); + + it('is empty when signed out', () => { + expect(defaultAuthorFor(null, null)).toBe(''); + }); +}); + +describe('formatSunsetDate', () => { + it('formats an RFC 3339 timestamp as the UTC calendar date', () => { + expect(formatSunsetDate('2027-01-31T00:00:00+00:00')).toBe('2027-01-31'); + expect(formatSunsetDate('2027-01-31T23:30:00-05:00')).toBe('2027-02-01'); + }); + + it('passes an unparseable value through', () => { + expect(formatSunsetDate('soon')).toBe('soon'); + }); +}); diff --git a/src/__tests__/providers/SessionProvider.test.tsx b/src/__tests__/providers/SessionProvider.test.tsx index f8e590b..2d955d6 100644 --- a/src/__tests__/providers/SessionProvider.test.tsx +++ b/src/__tests__/providers/SessionProvider.test.tsx @@ -1,42 +1,67 @@ import type { ReactNode } from 'react'; +import { InteractionRequiredAuthError, ServerError } from '@azure/msal-browser'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { render, screen, waitFor } from '@testing-library/react'; +import { act, render, screen, waitFor } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { useSession } from '@/hooks/useSession'; +import { createFakeEntraClient, type EntraClient, type FakeEntraClient } from '@/lib/entra'; import { SESSION_STORAGE_KEYS } from '@/lib/session'; import { SessionProvider } from '@/providers/SessionProvider'; import { apiErrorResponse, jsonResponse, stubFetch } from '../utils/api-stub'; -const PRINCIPAL = { avatarUrl: null, login: 'tester', name: null, scheme: 'github' }; +const GITHUB_PRINCIPAL = { avatarUrl: null, id: '1', login: 'tester', name: null, scheme: 'github' }; +const ENTRA_PRINCIPAL = { + email: 'jasonp@emergentsoftware.net', + id: 'oid-1', + login: 'jasonp@emergentsoftware.net', + name: 'Jason Paff', + scheme: 'entra', +}; +const ENTRA_ACCOUNT = { homeAccountId: 'home-1', name: 'Jason Paff', username: 'jasonp@emergentsoftware.net' }; function Probe() { const session = useSession(); return (
{session.status} + {session.scheme ?? ''} {session.user?.login ?? ''} {session.api ? 'yes' : 'no'} + {session.notice?.kind ?? ''} + {session.notice?.message ?? ''} +
); } -function wrap(children: ReactNode) { +function renderProbe(entraClient: FakeEntraClient = createFakeEntraClient()) { + render(wrap(, entraClient)); + return entraClient; +} + +function wrap(children: ReactNode, entraClient: EntraClient) { const client = new QueryClient({ defaultOptions: { queries: { gcTime: 0, retry: false, staleTime: Infinity } }, }); return ( - {children} + {children} ); } +const status = () => screen.getByTestId('status').textContent; + describe('SessionProvider', () => { beforeEach(() => { window.sessionStorage.clear(); + window.location.hash = ''; }); afterEach(() => { @@ -45,89 +70,223 @@ describe('SessionProvider', () => { vi.unstubAllGlobals(); }); - it('is signed-out with no API client when no token is in sessionStorage', () => { - const { calls } = stubFetch(() => jsonResponse(PRINCIPAL)); - render(wrap()); - expect(screen.getByTestId('status')).toHaveTextContent('signed-out'); - expect(screen.getByTestId('has-api')).toHaveTextContent('no'); - expect(calls).toHaveLength(0); - }); + describe('startup', () => { + it('is verifying until MSAL has initialized, then signed-out with no API client and no requests', async () => { + const { calls } = stubFetch(() => jsonResponse(GITHUB_PRINCIPAL)); + const entra = renderProbe(); - it('rehydrates the token from sessionStorage on mount and verifies membership via GET /me', async () => { - window.sessionStorage.setItem(SESSION_STORAGE_KEYS.token, 'gho_rehydrated'); - const { calls } = stubFetch(() => jsonResponse(PRINCIPAL)); + expect(status()).toBe('verifying'); + await waitFor(() => expect(status()).toBe('signed-out')); + expect(screen.getByTestId('has-api')).toHaveTextContent('no'); + expect(screen.getByTestId('scheme')).toHaveTextContent(''); + expect(entra.calls.map((c) => c.method)).toEqual(['initialize', 'handleRedirect']); + expect(calls).toHaveLength(0); + }); - render(wrap()); + it('completes an Entra redirect return: the account becomes the session and /me runs with its token', async () => { + const { calls } = stubFetch(() => jsonResponse(ENTRA_PRINCIPAL)); + renderProbe(createFakeEntraClient({ redirectResult: ENTRA_ACCOUNT, token: 'eyJ.entra.token' })); - // Starts in verifying while the query runs. - expect(screen.getByTestId('status')).toHaveTextContent('verifying'); + await waitFor(() => expect(status()).toBe('member')); + expect(screen.getByTestId('scheme')).toHaveTextContent('entra'); + expect(screen.getByTestId('user')).toHaveTextContent('jasonp@emergentsoftware.net'); + expect(calls).toHaveLength(1); + expect(calls[0]!.url).toBe('http://localhost:7071/me'); + expect(calls[0]!.headers.get('authorization')).toBe('Bearer eyJ.entra.token'); + }); - // Resolves to member when /me succeeds (the API enforces org membership). - await waitFor(() => expect(screen.getByTestId('status')).toHaveTextContent('member')); - expect(screen.getByTestId('user')).toHaveTextContent('tester'); - expect(screen.getByTestId('has-api')).toHaveTextContent('yes'); + it('resumes an Entra session already cached in this tab (reload) and drops a leftover GitHub token', async () => { + window.sessionStorage.setItem(SESSION_STORAGE_KEYS.token, 'gho_leftover'); + const { calls } = stubFetch(() => jsonResponse(ENTRA_PRINCIPAL)); + renderProbe(createFakeEntraClient({ account: ENTRA_ACCOUNT })); - expect(calls).toHaveLength(1); - expect(calls[0]!.url).toBe('http://localhost:7071/me'); - expect(calls[0]!.headers.get('authorization')).toBe('Bearer gho_rehydrated'); - }); + await waitFor(() => expect(status()).toBe('member')); + expect(screen.getByTestId('scheme')).toHaveTextContent('entra'); + expect(window.sessionStorage.getItem(SESSION_STORAGE_KEYS.token)).toBeNull(); + expect(calls[0]!.headers.get('authorization')).toBe('Bearer entra-access-token'); + }); - it('transitions to non-member on 403 not_org_member', async () => { - window.sessionStorage.setItem(SESSION_STORAGE_KEYS.token, 'gho_nonmember'); - stubFetch(() => apiErrorResponse(403, 'not_org_member', 'Not an active member')); + it('shows a sign_in_failed notice and stays signed out when the redirect return fails', async () => { + stubFetch(() => jsonResponse(ENTRA_PRINCIPAL)); + renderProbe( + createFakeEntraClient({ + redirectError: new ServerError('access_denied', 'c', 'AADSTS65004: User declined to consent.'), + }), + ); - render(wrap()); + await waitFor(() => expect(status()).toBe('signed-out')); + expect(screen.getByTestId('notice-kind')).toHaveTextContent('sign_in_failed'); + expect(screen.getByTestId('notice-message')).toHaveTextContent(/access_denied.*declined to consent/); - await waitFor(() => expect(screen.getByTestId('status')).toHaveTextContent('non-member')); - expect(window.sessionStorage.getItem(SESSION_STORAGE_KEYS.token)).toBe('gho_nonmember'); + screen.getByTestId('dismiss').click(); + await waitFor(() => expect(screen.getByTestId('notice-kind')).toHaveTextContent('')); + }); }); - it('transitions to non-member on 403 org_membership_unverifiable and logs the SAML / OAuth-App hints', async () => { - window.sessionStorage.setItem(SESSION_STORAGE_KEYS.token, 'gho_unverifiable'); - stubFetch(() => apiErrorResponse(403, 'org_membership_unverifiable', 'Could not verify membership')); - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + describe('Entra session', () => { + it('transitions to non-member on 403 guest_not_allowed', async () => { + stubFetch(() => apiErrorResponse(403, 'guest_not_allowed', 'Guests are not allowed')); + renderProbe(createFakeEntraClient({ account: ENTRA_ACCOUNT })); - render(wrap()); + await waitFor(() => expect(status()).toBe('non-member')); + expect(screen.getByTestId('scheme')).toHaveTextContent('entra'); + }); - await waitFor(() => expect(screen.getByTestId('status')).toHaveTextContent('non-member')); - await waitFor(() => expect(warn).toHaveBeenCalled()); - const payload = JSON.stringify(warn.mock.calls[0]); - expect(payload).toMatch(/policies\/applications/); - expect(payload).toMatch(/SAML/); - }); + it('ends the session with an entra_expired notice when the silent token request needs interaction', async () => { + stubFetch(() => jsonResponse(ENTRA_PRINCIPAL)); + const entra = renderProbe( + createFakeEntraClient({ + account: ENTRA_ACCOUNT, + tokenError: new InteractionRequiredAuthError('login_required', 'c'), + }), + ); - it('returns to signed-out and clears the stored token on 401', async () => { - window.sessionStorage.setItem(SESSION_STORAGE_KEYS.token, 'gho_expired'); - stubFetch(() => apiErrorResponse(401, 'unauthorized', 'Bad credentials')); + await waitFor(() => expect(status()).toBe('signed-out')); + expect(screen.getByTestId('notice-kind')).toHaveTextContent('entra_expired'); + expect(screen.getByTestId('notice-message')).toHaveTextContent( + 'Your Emergent sign-in has expired. Sign in again.', + ); + expect(entra.calls.map((c) => c.method)).toContain('clearAccount'); + expect(screen.getByTestId('has-api')).toHaveTextContent('no'); + }); - render(wrap()); + it('signOut() hands off to the Entra logout redirect', async () => { + stubFetch(() => jsonResponse(ENTRA_PRINCIPAL)); + const entra = renderProbe(createFakeEntraClient({ account: ENTRA_ACCOUNT })); + await waitFor(() => expect(status()).toBe('member')); - await waitFor(() => expect(screen.getByTestId('status')).toHaveTextContent('signed-out')); - expect(window.sessionStorage.getItem(SESSION_STORAGE_KEYS.token)).toBeNull(); - expect(screen.getByTestId('has-api')).toHaveTextContent('no'); + act(() => screen.getByTestId('sign-out').click()); + + await waitFor(() => expect(status()).toBe('signed-out')); + expect(entra.calls.map((c) => c.method)).toContain('logout'); + }); + + it('signIn("entra") clears a GitHub session first and starts the redirect with the return path', async () => { + window.sessionStorage.setItem(SESSION_STORAGE_KEYS.token, 'gho_current'); + window.sessionStorage.setItem(SESSION_STORAGE_KEYS.oauthState, '{"state":"s","returnPath":"/"}'); + stubFetch(() => jsonResponse(GITHUB_PRINCIPAL)); + const entra = renderProbe(); + await waitFor(() => expect(status()).toBe('member')); + + act(() => screen.getByTestId('sign-in-entra').click()); + + await waitFor(() => expect(status()).toBe('authenticating')); + expect(entra.calls).toContainEqual({ method: 'login', returnPath: '/bundles' }); + expect(window.sessionStorage.getItem(SESSION_STORAGE_KEYS.token)).toBeNull(); + expect(window.sessionStorage.getItem(SESSION_STORAGE_KEYS.oauthState)).toBeNull(); + }); + + it('signIn("github") clears an Entra session before redirecting to GitHub', async () => { + stubFetch(() => jsonResponse(ENTRA_PRINCIPAL)); + const assign = vi.fn(); + vi.stubGlobal('location', { ...window.location, assign, hash: '#/contribute' }); + const entra = renderProbe(createFakeEntraClient({ account: ENTRA_ACCOUNT })); + await waitFor(() => expect(status()).toBe('member')); + + act(() => screen.getByTestId('sign-in-github').click()); + + await waitFor(() => expect(status()).toBe('authenticating')); + expect(entra.calls.map((c) => c.method)).toContain('clearAccount'); + expect(assign).toHaveBeenCalledWith(expect.stringMatching(/^https:\/\/github\.com\/login\/oauth\/authorize\?/)); + const stored = JSON.parse(window.sessionStorage.getItem(SESSION_STORAGE_KEYS.oauthState) ?? '{}') as { + returnPath?: string; + }; + expect(stored.returnPath).toBe('/contribute'); + }); }); - it('signOut() clears the token and returns to signed-out', async () => { - window.sessionStorage.setItem(SESSION_STORAGE_KEYS.token, 'gho_bye'); - stubFetch(() => jsonResponse(PRINCIPAL)); + describe('GitHub session (unchanged)', () => { + it('rehydrates the token from sessionStorage on mount and verifies membership via GET /me', async () => { + window.sessionStorage.setItem(SESSION_STORAGE_KEYS.token, 'gho_rehydrated'); + const { calls } = stubFetch(() => jsonResponse(GITHUB_PRINCIPAL)); - function SignOutButton() { - const { signOut, status } = useSession(); - return ( - - ); - } + renderProbe(); + + expect(status()).toBe('verifying'); + await waitFor(() => expect(status()).toBe('member')); + expect(screen.getByTestId('scheme')).toHaveTextContent('github'); + expect(screen.getByTestId('user')).toHaveTextContent('tester'); + expect(screen.getByTestId('has-api')).toHaveTextContent('yes'); + + expect(calls).toHaveLength(1); + expect(calls[0]!.url).toBe('http://localhost:7071/me'); + expect(calls[0]!.headers.get('authorization')).toBe('Bearer gho_rehydrated'); + }); + + it('transitions to non-member on 403 not_org_member', async () => { + window.sessionStorage.setItem(SESSION_STORAGE_KEYS.token, 'gho_nonmember'); + stubFetch(() => apiErrorResponse(403, 'not_org_member', 'Not an active member')); + + renderProbe(); + + await waitFor(() => expect(status()).toBe('non-member')); + expect(window.sessionStorage.getItem(SESSION_STORAGE_KEYS.token)).toBe('gho_nonmember'); + }); + + it('transitions to non-member on 403 org_membership_unverifiable and logs the SAML / OAuth-App hints', async () => { + window.sessionStorage.setItem(SESSION_STORAGE_KEYS.token, 'gho_unverifiable'); + stubFetch(() => apiErrorResponse(403, 'org_membership_unverifiable', 'Could not verify membership')); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + renderProbe(); + + await waitFor(() => expect(status()).toBe('non-member')); + await waitFor(() => expect(warn).toHaveBeenCalled()); + const payload = JSON.stringify(warn.mock.calls[0]); + expect(payload).toMatch(/policies\/applications/); + expect(payload).toMatch(/SAML/); + }); + + it('returns to signed-out and clears the stored token on 401, with no notice', async () => { + window.sessionStorage.setItem(SESSION_STORAGE_KEYS.token, 'gho_expired'); + stubFetch(() => apiErrorResponse(401, 'invalid_token', 'Bad credentials')); + + renderProbe(); + + await waitFor(() => expect(status()).toBe('signed-out')); + expect(window.sessionStorage.getItem(SESSION_STORAGE_KEYS.token)).toBeNull(); + expect(screen.getByTestId('has-api')).toHaveTextContent('no'); + expect(screen.getByTestId('notice-kind')).toHaveTextContent(''); + }); + + it('keeps the API message verbatim as the github_auth_retired notice on 401 github_auth_retired', async () => { + window.sessionStorage.setItem(SESSION_STORAGE_KEYS.token, 'gho_retired'); + const message = + 'GitHub sign-in to ATK ended on 2026-09-14. Update the CLI (`npm install -g @detergent-software/atk@latest`) and run `atk login`, or sign in to the web app with your Emergent account.'; + stubFetch(() => apiErrorResponse(401, 'github_auth_retired', message)); + + renderProbe(); + + await waitFor(() => expect(status()).toBe('signed-out')); + expect(window.sessionStorage.getItem(SESSION_STORAGE_KEYS.token)).toBeNull(); + expect(screen.getByTestId('notice-kind')).toHaveTextContent('github_auth_retired'); + expect(screen.getByTestId('notice-message')).toHaveTextContent(message); + }); + + it('signOut() clears the token and returns to signed-out without touching Entra', async () => { + window.sessionStorage.setItem(SESSION_STORAGE_KEYS.token, 'gho_bye'); + stubFetch(() => jsonResponse(GITHUB_PRINCIPAL)); + const entra = renderProbe(); + await waitFor(() => expect(status()).toBe('member')); + + act(() => screen.getByTestId('sign-out').click()); - render(wrap()); + await waitFor(() => expect(status()).toBe('signed-out')); + expect(window.sessionStorage.getItem(SESSION_STORAGE_KEYS.token)).toBeNull(); + expect(entra.calls.map((c) => c.method)).not.toContain('logout'); + }); - // Wait for verify query to settle before signing out. - await waitFor(() => expect(screen.getByTestId('sign-out').getAttribute('data-status')).toBe('member')); + it('exposes githubAuthSunset from the principal on the user', async () => { + window.sessionStorage.setItem(SESSION_STORAGE_KEYS.token, 'gho_sunset'); + stubFetch(() => jsonResponse({ ...GITHUB_PRINCIPAL, githubAuthSunset: '2027-01-31T00:00:00+00:00' })); - screen.getByTestId('sign-out').click(); + function SunsetProbe() { + const { user } = useSession(); + return {user?.githubAuthSunset ?? ''}; + } + render(wrap(, createFakeEntraClient())); - await waitFor(() => expect(screen.getByTestId('sign-out').getAttribute('data-status')).toBe('signed-out')); - expect(window.sessionStorage.getItem(SESSION_STORAGE_KEYS.token)).toBeNull(); + await waitFor(() => expect(screen.getByTestId('sunset')).toHaveTextContent('2027-01-31T00:00:00+00:00')); + }); }); }); diff --git a/src/__tests__/routes/Contribute.test.tsx b/src/__tests__/routes/Contribute.test.tsx index d5f8edf..80fa6e1 100644 --- a/src/__tests__/routes/Contribute.test.tsx +++ b/src/__tests__/routes/Contribute.test.tsx @@ -1,10 +1,11 @@ import { Toast } from '@base-ui-components/react/toast'; -import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import JSZip from 'jszip'; import { MemoryRouter } from 'react-router'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { ApiClient } from '@/lib/api-client'; +import type { SessionContextValue } from '@/providers/SessionProvider'; import { Toaster } from '@/components/Toaster'; import * as publishServiceModule from '@/lib/publish-service'; @@ -111,11 +112,16 @@ async function makeSkillFile(name: string, entries: Record } = {}, +) { const session = makeSessionValue({ api: options.api ?? null, + scheme: 'github', status: 'member', - user: { login, name: null }, + user: { id: '1', login, name: null, scheme: 'github' }, + ...options.session, }); return render( @@ -296,6 +302,23 @@ describe('Contribute — wizard UI', () => { expect(persisted).toContain('"author":"octo-login"'); }); + it('prefills the author field from the display name for an Entra session, falling back to the UPN', () => { + renderContribute('jasonp@emergentsoftware.net', { + session: { + scheme: 'entra', + user: { id: 'oid', login: 'jasonp@emergentsoftware.net', name: 'Jason Paff', scheme: 'entra' }, + }, + }); + expect(window.sessionStorage.getItem(DRAFT_STORAGE_KEY)).toContain('"author":"Jason Paff"'); + cleanup(); + window.sessionStorage.clear(); + + renderContribute('jasonp@emergentsoftware.net', { + session: { scheme: 'entra', user: { id: 'oid', login: 'jasonp@emergentsoftware.net', scheme: 'entra' } }, + }); + expect(window.sessionStorage.getItem(DRAFT_STORAGE_KEY)).toContain('"author":"jasonp@emergentsoftware.net"'); + }); + it('persists draft changes to sessionStorage and hydrates on mount', () => { renderContribute(); fireEvent.click(screen.getByTestId('asset-type-rule')); diff --git a/src/__tests__/routes/CreateBundle.test.tsx b/src/__tests__/routes/CreateBundle.test.tsx index c0057c8..5c5c721 100644 --- a/src/__tests__/routes/CreateBundle.test.tsx +++ b/src/__tests__/routes/CreateBundle.test.tsx @@ -34,7 +34,7 @@ function renderCreateBundle(api: ApiClient | null = makeTestApiClient()) { const session = makeSessionValue({ api, status: 'member', - user: { login: 'test-user', name: null }, + user: { id: '1', login: 'test-user', name: null, scheme: 'github' }, }); return render( @@ -225,7 +225,7 @@ describe('CreateBundle — wizard flow', () => { const session = makeSessionValue({ api: makeTestApiClient(), status: 'member', - user: { login: 'test-user', name: null }, + user: { id: '1', login: 'test-user', name: null, scheme: 'github' }, }); render( + + , + ); +} + +describe('NotAuthorizedRoute', () => { + it('keeps the GitHub org copy for a GitHub session', () => { + const signOut = vi.fn(); + renderRoute( + makeSessionValue({ + scheme: 'github', + signOut, + status: 'non-member', + user: { id: '1', login: 'octo', scheme: 'github' }, + }), + ); + + expect( + screen.getByText(/Your GitHub account is not a member of the Emergent Software organization/), + ).toBeInTheDocument(); + expect(screen.getByTestId('not-authorized-content')).toHaveTextContent('signed in as octo'); + expect(screen.getByTestId('not-authorized-content')).toHaveTextContent(/EmergentSoftware GitHub organization/); + fireEvent.click(screen.getByRole('button', { name: 'Sign out' })); + expect(signOut).toHaveBeenCalledTimes(1); + }); + + it('explains the guest refusal for an Entra session', () => { + const signOut = vi.fn(); + renderRoute( + makeSessionValue({ + scheme: 'entra', + signOut, + status: 'non-member', + user: { id: 'oid', login: 'guest_outlook.com#EXT#@emergentsoftware.onmicrosoft.com', scheme: 'entra' }, + }), + ); + + expect(screen.getByText('Your account is a guest in the Emergent Software tenant.')).toBeInTheDocument(); + expect(screen.getByTestId('not-authorized-content')).toHaveTextContent( + 'ATK is available to Emergent Software staff; sign out and use your Emergent account, or sign in with GitHub.', + ); + expect(screen.queryByText(/read:org/)).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Sign out' })); + expect(signOut).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/__tests__/routes/SignIn.test.tsx b/src/__tests__/routes/SignIn.test.tsx new file mode 100644 index 0000000..06c0f79 --- /dev/null +++ b/src/__tests__/routes/SignIn.test.tsx @@ -0,0 +1,57 @@ +import { render, screen } from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { SESSION_STORAGE_KEYS } from '@/lib/session'; +import { type SessionContextValue } from '@/providers/SessionProvider'; +import { SignInRoute } from '@/routes/SignIn'; + +import { makeSessionValue, SessionHarness } from '../utils/session-harness'; + +/** The router carries the query (as HashRouter does inside the fragment); `window.location.search` stays empty. */ +function renderSignIn(path: string, session: SessionContextValue) { + return render( + + + + } path='/sign-in' /> + home} path='/' /> + na} path='/not-authorized' /> + + + , + ); +} + +describe('SignInRoute', () => { + beforeEach(() => window.sessionStorage.clear()); + afterEach(() => window.sessionStorage.clear()); + + it('starts an Entra sign-in by default, restoring the stashed return path', () => { + window.sessionStorage.setItem(SESSION_STORAGE_KEYS.pendingReturn, '/bundles'); + const signIn = vi.fn(); + renderSignIn('/sign-in', makeSessionValue({ signIn })); + + expect(signIn).toHaveBeenCalledWith('entra', '/bundles'); + expect(screen.getByText(/Redirecting to Microsoft/)).toBeInTheDocument(); + }); + + it('starts a GitHub sign-in with ?provider=github', () => { + const signIn = vi.fn(); + renderSignIn('/sign-in?provider=github', makeSessionValue({ signIn })); + + expect(signIn).toHaveBeenCalledWith('github', undefined); + expect(screen.getByText(/Redirecting to GitHub/)).toBeInTheDocument(); + }); + + it('forwards members home and non-members to /not-authorized', () => { + const signIn = vi.fn(); + const { unmount } = renderSignIn('/sign-in', makeSessionValue({ scheme: 'entra', signIn, status: 'member' })); + expect(screen.getByTestId('home')).toBeInTheDocument(); + unmount(); + + renderSignIn('/sign-in', makeSessionValue({ scheme: 'github', signIn, status: 'non-member' })); + expect(screen.getByTestId('not-authorized')).toBeInTheDocument(); + expect(signIn).not.toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/routes/navigation.test.tsx b/src/__tests__/routes/navigation.test.tsx index 2bcb499..f6146d8 100644 --- a/src/__tests__/routes/navigation.test.tsx +++ b/src/__tests__/routes/navigation.test.tsx @@ -1,8 +1,10 @@ import { render, screen } from '@testing-library/react'; +import { NuqsTestingAdapter } from 'nuqs/adapters/testing'; import { MemoryRouter } from 'react-router'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { App } from '@/App'; +import { createFakeEntraClient } from '@/lib/entra'; /** * Routing smoke tests. Auth-protected routes redirect unauthenticated users @@ -31,24 +33,28 @@ describe('route navigation (signed-out)', () => { beforeEach(() => window.sessionStorage.clear()); afterEach(() => window.sessionStorage.clear()); - it.each(SIGNED_OUT_CASES)('renders $path with its PageHeader title', ({ heading, path }) => { + it.each(SIGNED_OUT_CASES)('renders $path with its PageHeader title', async ({ heading, path }) => { render( - + + + , ); - expect(screen.getByRole('heading', { level: 1, name: heading })).toBeInTheDocument(); + expect(await screen.findByRole('heading', { level: 1, name: heading })).toBeInTheDocument(); expect(screen.getByTestId('app-layout')).toBeInTheDocument(); }); - it.each(REDIRECTED_CASES)('redirects $path to the landing when signed out', ({ heading, path }) => { + it.each(REDIRECTED_CASES)('redirects $path to the landing when signed out', async ({ heading, path }) => { render( - + + + , ); - expect(screen.getByRole('heading', { level: 1, name: heading })).toBeInTheDocument(); + expect(await screen.findByRole('heading', { level: 1, name: heading })).toBeInTheDocument(); }); }); diff --git a/src/__tests__/utils/api-stub.ts b/src/__tests__/utils/api-stub.ts index 3722de3..f672352 100644 --- a/src/__tests__/utils/api-stub.ts +++ b/src/__tests__/utils/api-stub.ts @@ -41,7 +41,7 @@ export function jsonResponse(body: unknown, status = 200): Response { /** Build a real API client (bearer auth + retries) pointed at the test base URL. */ export function makeTestApiClient(token: null | string = 'test-token', retry = fastRetry): ApiClient { - return createApiClient(token, { retry }); + return createApiClient(token === null ? null : { scheme: 'github', token }, { retry }); } /** diff --git a/src/__tests__/utils/session-harness.tsx b/src/__tests__/utils/session-harness.tsx index 872b28e..de60934 100644 --- a/src/__tests__/utils/session-harness.tsx +++ b/src/__tests__/utils/session-harness.tsx @@ -15,10 +15,12 @@ export function makeSessionValue(overrides: Partial = {}): return { api: null, completeSignIn: () => {}, + dismissNotice: () => {}, + notice: null, + scheme: null, signIn: () => {}, signOut: () => {}, status: 'signed-out', - token: null, user: null, ...overrides, }; diff --git a/src/auth-redirect.ts b/src/auth-redirect.ts new file mode 100644 index 0000000..ca9cc5f --- /dev/null +++ b/src/auth-redirect.ts @@ -0,0 +1,11 @@ +/** + * MSAL v5 redirect bridge. Microsoft Entra returns to this page after sign-in + * and sign-out; it hands the response to MSAL, which navigates back to the + * route that started the flow. It is a separate Vite entry (see vite.config.ts) + * and must be served from the app's own origin. + */ +import { broadcastResponseToMainFrame } from '@azure/msal-browser/redirect-bridge'; + +broadcastResponseToMainFrame().catch((error: unknown) => { + console.error('[auth-redirect] Could not hand the sign-in response to the app:', error); +}); diff --git a/src/components/RequireAuth.tsx b/src/components/RequireAuth.tsx index d7567f1..3c2c446 100644 --- a/src/components/RequireAuth.tsx +++ b/src/components/RequireAuth.tsx @@ -11,7 +11,8 @@ interface RequireAuthProps { /** * Route guard that allows the active session to reach `children` only when the - * user is a verified member of the Emergent Software org. Unauthenticated users + * API has verified the user (an EmergentSoftware org member, or an Emergent + * Software tenant member). Unauthenticated users * are redirected to `/` (after stashing their intended path for restoration on * sign-in) and non-members are redirected to `/not-authorized`. */ @@ -35,7 +36,7 @@ export function RequireAuth({ children }: RequireAuthProps) { className='flex min-h-[60vh] items-center justify-center' data-testid='require-auth-loading' > - + ); } diff --git a/src/components/SignedOutLanding.tsx b/src/components/SignedOutLanding.tsx index 742c34a..02ad838 100644 --- a/src/components/SignedOutLanding.tsx +++ b/src/components/SignedOutLanding.tsx @@ -1,10 +1,12 @@ +import { X } from 'lucide-react'; + import { Button } from '@/components/ui/button'; import { useSession } from '@/hooks/useSession'; import { consumePendingReturnPath } from '@/lib/session'; /** Friendly intro shown at `/` when the viewer is signed out. */ export function SignedOutLanding() { - const { signIn } = useSession(); + const { dismissNotice, notice, signIn } = useSession(); return (

Agentic Toolkit

- A curated registry of skills, agents, rules, hooks, and bundles for AI coding tools. - Sign in with your GitHub account to browse and download assets. + A curated registry of skills, agents, rules, hooks, and bundles for AI coding tools. Sign in to browse and + download assets.

- Access is limited to members of the EmergentSoftware organization. + Access is limited to Emergent Software staff and members of the{' '} + EmergentSoftware GitHub organization.

- + {notice ? ( +
+

{notice.message}

+ +
+ ) : null} +
+ + +
); } diff --git a/src/components/layout/AppLayout.tsx b/src/components/layout/AppLayout.tsx index c0f3798..523a9a8 100644 --- a/src/components/layout/AppLayout.tsx +++ b/src/components/layout/AppLayout.tsx @@ -5,6 +5,7 @@ import { cn } from '@/lib/utils'; import { Header } from './Header'; import { LayoutWidthProvider, useLayoutWidth } from './LayoutWidthContext'; +import { SunsetBanner } from './SunsetBanner'; export function AppLayout() { return ( @@ -19,6 +20,7 @@ function AppLayoutInner() { return (
+
signIn(consumePendingReturnPath())} + onClick={() => signIn('entra', consumePendingReturnPath())} size='sm' variant='outline' > diff --git a/src/components/layout/SunsetBanner.tsx b/src/components/layout/SunsetBanner.tsx new file mode 100644 index 0000000..3ab28a0 --- /dev/null +++ b/src/components/layout/SunsetBanner.tsx @@ -0,0 +1,44 @@ +import { X } from 'lucide-react'; +import { useState } from 'react'; + +import { Button } from '@/components/ui/button'; +import { useSession } from '@/hooks/useSession'; +import { formatSunsetDate, SESSION_STORAGE_KEYS } from '@/lib/session'; + +/** + * Slim banner under the header while a GitHub session runs and the API has + * scheduled the end of GitHub sign-in (`githubAuthSunset` on the principal). + * Never shown for Entra sessions. Dismissal lasts for the tab. + */ +export function SunsetBanner() { + const { scheme, signIn, user } = useSession(); + const [dismissed, setDismissed] = useState(() => readDismissed()); + + const sunset = scheme === 'github' ? user?.githubAuthSunset : undefined; + if (!sunset || dismissed) return null; + + const dismiss = () => { + window.sessionStorage.setItem(SESSION_STORAGE_KEYS.sunsetDismissed, '1'); + setDismissed(true); + }; + + return ( +
+
+

+ GitHub sign-in to ATK ends on {formatSunsetDate(sunset)}. Switch to your Emergent account before then. +

+ + +
+
+ ); +} + +function readDismissed(): boolean { + return window.sessionStorage.getItem(SESSION_STORAGE_KEYS.sunsetDismissed) === '1'; +} diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 6a5a839..e1dd34a 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -3,8 +3,8 @@ * * Owns three concerns the generated code does not: * - Base URL resolution from `VITE_ATK_API_URL`. - * - Wiring: bearer auth from the session token and retries through - * {@link fetchWithRetry}. + * - Wiring: bearer auth from the session credential (a GitHub token, or an + * Entra token getter) and retries through {@link fetchWithRetry}. * - Error mapping: every non-2xx result becomes an {@link ApiRequestError} * with a stable `code`, an HTTP `status`, and a message the UI can show. * @@ -23,6 +23,19 @@ export type { ApiErrorDetail } from './api/types.gen'; /** A configured ATK API client: base URL, bearer auth, and retries wired in. */ export type ApiClient = Client; +/** + * What {@link createApiClient} sends as the bearer token. + * + * `null` is the one unauthenticated call (the GitHub code exchange). A GitHub + * credential is the stored token itself. An Entra credential is a getter, + * because MSAL serves the access token from its cache and refreshes it when + * it nears expiry; the getter runs on every request. + */ +export type ApiCredential = + | null + | { getToken: () => Promise; scheme: 'entra' } + | { scheme: 'github'; token: string }; + /** Shape of a generated SDK call result with `throwOnError: false` and `responseStyle: 'fields'`. */ export interface ApiResult { data?: T; @@ -70,20 +83,31 @@ export class ApiRequestError extends Error { } } -/** Codes the API uses for a 403 that means "signed in, but not an org member". */ -export const NOT_MEMBER_CODES = new Set(['not_org_member', 'org_membership_unverifiable']); +/** + * Codes the API uses for a 403 that means "signed in, but not allowed in": + * a GitHub account outside the EmergentSoftware org (or unverifiable), or an + * Entra guest account. + */ +export const NOT_MEMBER_CODES = new Set(['guest_not_allowed', 'not_org_member', 'org_membership_unverifiable']); + +/** Code of the synthetic 401 thrown when the Entra session can no longer be renewed silently. */ +export const SESSION_EXPIRED_CODE = 'session_expired'; /** - * Build an ATK API client for a session token. + * Build an ATK API client for a session credential. * - * `token` may be `null` for the one unauthenticated call (the OAuth code - * exchange); the bearer header is simply omitted. Every request is routed + * With `null` the bearer header is simply omitted. Every request is routed * through {@link fetchWithRetry}, so 429/5xx responses and network errors are - * retried with backoff before the caller sees them. + * retried with backoff before the caller sees them. A rejected Entra token + * getter surfaces as the result's `error` with no `response`; {@link unwrap} + * rethrows it unchanged, so callers see the 401 it carries. */ -export function createApiClient(token: null | string, options: CreateApiClientOptions = {}): ApiClient { +export function createApiClient(credential: ApiCredential, options: CreateApiClientOptions = {}): ApiClient { return createClient({ - auth: () => token ?? undefined, + auth: async () => { + if (!credential) return undefined; + return credential.scheme === 'github' ? credential.token : credential.getToken(); + }, baseUrl: getApiUrl(), fetch: (input: Request | string | URL, init?: RequestInit) => fetchWithRetry(input, init, options.retry), throwOnError: false, @@ -110,6 +134,15 @@ export function isApiError(err: unknown, code?: string, status?: number): err is return true; } +/** The error an Entra credential throws once the session is over; a 401 to every caller. */ +export function sessionExpiredError(): ApiRequestError { + return new ApiRequestError('Your Emergent sign-in has expired. Sign in again.', { + code: SESSION_EXPIRED_CODE, + resource: 'your session', + status: 401, + }); +} + /** * Convert a generated-client result into its `data`, or throw an * {@link ApiRequestError} describing why the call failed. @@ -120,6 +153,9 @@ export function isApiError(err: unknown, code?: string, status?: number): err is export function unwrap(result: ApiResult, resource: string): T { const { data, error, response } = result; + // Already mapped before the request was sent (an expired Entra session). + if (error instanceof ApiRequestError) throw error; + if (response === undefined) { // The fetch itself threw (offline, DNS, CORS, abort, ...). const cause = error instanceof Error ? error.message : error ? String(error) : 'Network request failed'; @@ -163,12 +199,14 @@ function toApiRequestError(status: number, error: unknown, resource: string): Ap const make = (message: string) => new ApiRequestError(message, { code, details, resource, status }); if (status === 401) { - return make('Your GitHub session is no longer valid. Sign in again.'); + // GitHub sign-in has ended: the API's message says when and what to do instead. Show it as-is. + if (code === 'github_auth_retired' && apiMessage) return make(apiMessage); + return make('Your session is no longer valid. Sign in again.'); } if (status === 403 && NOT_MEMBER_CODES.has(code)) { return make( - 'Your GitHub account is not an active member of the EmergentSoftware organization, or membership could not be verified.', + 'Your account is not allowed to use ATK: GitHub accounts must be active members of the EmergentSoftware organization (or membership could not be verified), and Emergent accounts must be members of the Emergent Software tenant, not guests.', ); } diff --git a/src/lib/api/sdk.gen.ts b/src/lib/api/sdk.gen.ts index 50b5498..6239478 100644 --- a/src/lib/api/sdk.gen.ts +++ b/src/lib/api/sdk.gen.ts @@ -41,7 +41,7 @@ export const authGitHubExchange = (options }); /** - * The authenticated caller as resolved by the API + * The authenticated caller as resolved by the API. `github` principals carry `githubAuthSunset` while a GitHub sign-in cutoff is scheduled. */ export const me = (options?: Options): RequestResult => (options?.client ?? client).get({ security: [{ scheme: 'bearer', type: 'http' }], @@ -122,7 +122,7 @@ export const getBundleReadme = (options: O }); /** - * Zip of the bundle: `bundle.json` at the root and each member under `{member}/…`. `format=skill` drops `bundle.json` and nests skill members as `{member}.skill`. + * Zip of the bundle: `bundle.json` (plus the bundle's `README.md` when it has one) at the root and each member under `{member}/…`. `format=skill` drops `bundle.json` and `README.md` and nests skill members as `{member}.skill`. */ export const downloadBundle = (options: Options): RequestResult => (options.client ?? client).get({ security: [{ scheme: 'bearer', type: 'http' }], @@ -153,7 +153,7 @@ export const publishPlan = (options: Optio }); /** - * Validate, then create the branch, commit and pull request on the registry with the caller's own token so the PR is authored by them. Global targets get the default reviewers; org targets use `reviewers`. + * Validate, then create the branch, commit and pull request on the registry. GitHub callers write with their own token so the PR is authored by them; Entra callers are written by the API's publish identity with the commit authored as them, and every PR body names the publisher. Global targets get the default reviewers; org targets use `reviewers`. */ export const publish = (options: Options): RequestResult => (options.client ?? client).post({ security: [{ scheme: 'bearer', type: 'http' }], diff --git a/src/lib/api/types.gen.ts b/src/lib/api/types.gen.ts index ac748d2..132aeea 100644 --- a/src/lib/api/types.gen.ts +++ b/src/lib/api/types.gen.ts @@ -179,11 +179,15 @@ export type OAuthTokenResponse = { */ export type Principal = { /** - * Auth scheme that validated the token (`github`) + * Auth scheme that validated the token: `github` or `entra` */ scheme: string; /** - * GitHub login + * Stable user id: the GitHub numeric user id, or the Entra object id (`oid`) + */ + id: string; + /** + * GitHub login, or the Entra user principal name (e.g. `jasonp@emergentsoftware.net`) */ login: string; /** @@ -191,9 +195,17 @@ export type Principal = { */ name?: string | null; /** - * Avatar URL + * Email: the GitHub public profile email (often absent), or the Entra `email` claim falling back to the UPN + */ + email?: string | null; + /** + * Avatar URL (GitHub only) */ avatarUrl?: string | null; + /** + * Only on `github` principals while a GitHub sign-in cutoff is scheduled: when GitHub sign-in to ATK ends (RFC 3339, UTC). Absent otherwise. The same responses carry `Sunset` and `Deprecation` headers. + */ + githubAuthSunset?: string | null; }; /** @@ -422,13 +434,17 @@ export type MeData = { export type MeErrors = { /** - * Missing or invalid token + * Missing or invalid token (`unauthenticated`, `unsupported_token`, `invalid_token`), or GitHub sign-in has ended (`github_auth_retired`) */ 401: ApiError; /** - * Not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`) + * GitHub: not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`). Entra: a guest account (`guest_not_allowed`) */ 403: ApiError; + /** + * GitHub or Entra could not be reached to validate the token (`github_unavailable`, `entra_unavailable`) + */ + 502: ApiError; }; export type MeError = MeErrors[keyof MeErrors]; @@ -451,13 +467,17 @@ export type GetRegistryData = { export type GetRegistryErrors = { /** - * Missing or invalid token + * Missing or invalid token (`unauthenticated`, `unsupported_token`, `invalid_token`), or GitHub sign-in has ended (`github_auth_retired`) */ 401: ApiError; /** - * Not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`) + * GitHub: not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`). Entra: a guest account (`guest_not_allowed`) */ 403: ApiError; + /** + * GitHub or Entra could not be reached to validate the token (`github_unavailable`, `entra_unavailable`) + */ + 502: ApiError; }; export type GetRegistryError = GetRegistryErrors[keyof GetRegistryErrors]; @@ -500,17 +520,21 @@ export type GetAssetManifestData = { export type GetAssetManifestErrors = { /** - * Missing or invalid token + * Missing or invalid token (`unauthenticated`, `unsupported_token`, `invalid_token`), or GitHub sign-in has ended (`github_auth_retired`) */ 401: ApiError; /** - * Not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`) + * GitHub: not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`). Entra: a guest account (`guest_not_allowed`) */ 403: ApiError; /** * Unknown asset, version, or file */ 404: ApiError; + /** + * GitHub or Entra could not be reached to validate the token (`github_unavailable`, `entra_unavailable`) + */ + 502: ApiError; }; export type GetAssetManifestError = GetAssetManifestErrors[keyof GetAssetManifestErrors]; @@ -553,17 +577,21 @@ export type GetAssetReadmeData = { export type GetAssetReadmeErrors = { /** - * Missing or invalid token + * Missing or invalid token (`unauthenticated`, `unsupported_token`, `invalid_token`), or GitHub sign-in has ended (`github_auth_retired`) */ 401: ApiError; /** - * Not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`) + * GitHub: not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`). Entra: a guest account (`guest_not_allowed`) */ 403: ApiError; /** * Unknown asset, version, or file */ 404: ApiError; + /** + * GitHub or Entra could not be reached to validate the token (`github_unavailable`, `entra_unavailable`) + */ + 502: ApiError; }; export type GetAssetReadmeError = GetAssetReadmeErrors[keyof GetAssetReadmeErrors]; @@ -604,17 +632,21 @@ export type ListAssetFilesData = { export type ListAssetFilesErrors = { /** - * Missing or invalid token + * Missing or invalid token (`unauthenticated`, `unsupported_token`, `invalid_token`), or GitHub sign-in has ended (`github_auth_retired`) */ 401: ApiError; /** - * Not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`) + * GitHub: not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`). Entra: a guest account (`guest_not_allowed`) */ 403: ApiError; /** * Unknown asset, version, or file */ 404: ApiError; + /** + * GitHub or Entra could not be reached to validate the token (`github_unavailable`, `entra_unavailable`) + */ + 502: ApiError; }; export type ListAssetFilesError = ListAssetFilesErrors[keyof ListAssetFilesErrors]; @@ -659,17 +691,21 @@ export type GetAssetFileData = { export type GetAssetFileErrors = { /** - * Missing or invalid token + * Missing or invalid token (`unauthenticated`, `unsupported_token`, `invalid_token`), or GitHub sign-in has ended (`github_auth_retired`) */ 401: ApiError; /** - * Not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`) + * GitHub: not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`). Entra: a guest account (`guest_not_allowed`) */ 403: ApiError; /** * Unknown asset, version, or file */ 404: ApiError; + /** + * GitHub or Entra could not be reached to validate the token (`github_unavailable`, `entra_unavailable`) + */ + 502: ApiError; }; export type GetAssetFileError = GetAssetFileErrors[keyof GetAssetFileErrors]; @@ -714,17 +750,21 @@ export type DownloadAssetData = { export type DownloadAssetErrors = { /** - * Missing or invalid token + * Missing or invalid token (`unauthenticated`, `unsupported_token`, `invalid_token`), or GitHub sign-in has ended (`github_auth_retired`) */ 401: ApiError; /** - * Not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`) + * GitHub: not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`). Entra: a guest account (`guest_not_allowed`) */ 403: ApiError; /** * Unknown asset, version, or file */ 404: ApiError; + /** + * GitHub or Entra could not be reached to validate the token (`github_unavailable`, `entra_unavailable`) + */ + 502: ApiError; }; export type DownloadAssetError = DownloadAssetErrors[keyof DownloadAssetErrors]; @@ -761,17 +801,21 @@ export type GetBundleManifestData = { export type GetBundleManifestErrors = { /** - * Missing or invalid token + * Missing or invalid token (`unauthenticated`, `unsupported_token`, `invalid_token`), or GitHub sign-in has ended (`github_auth_retired`) */ 401: ApiError; /** - * Not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`) + * GitHub: not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`). Entra: a guest account (`guest_not_allowed`) */ 403: ApiError; /** * Unknown asset, version, or file */ 404: ApiError; + /** + * GitHub or Entra could not be reached to validate the token (`github_unavailable`, `entra_unavailable`) + */ + 502: ApiError; }; export type GetBundleManifestError = GetBundleManifestErrors[keyof GetBundleManifestErrors]; @@ -810,17 +854,21 @@ export type GetBundleReadmeData = { export type GetBundleReadmeErrors = { /** - * Missing or invalid token + * Missing or invalid token (`unauthenticated`, `unsupported_token`, `invalid_token`), or GitHub sign-in has ended (`github_auth_retired`) */ 401: ApiError; /** - * Not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`) + * GitHub: not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`). Entra: a guest account (`guest_not_allowed`) */ 403: ApiError; /** * Unknown asset, version, or file */ 404: ApiError; + /** + * GitHub or Entra could not be reached to validate the token (`github_unavailable`, `entra_unavailable`) + */ + 502: ApiError; }; export type GetBundleReadmeError = GetBundleReadmeErrors[keyof GetBundleReadmeErrors]; @@ -861,17 +909,21 @@ export type DownloadBundleData = { export type DownloadBundleErrors = { /** - * Missing or invalid token + * Missing or invalid token (`unauthenticated`, `unsupported_token`, `invalid_token`), or GitHub sign-in has ended (`github_auth_retired`) */ 401: ApiError; /** - * Not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`) + * GitHub: not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`). Entra: a guest account (`guest_not_allowed`) */ 403: ApiError; /** * Unknown asset, version, or file */ 404: ApiError; + /** + * GitHub or Entra could not be reached to validate the token (`github_unavailable`, `entra_unavailable`) + */ + 502: ApiError; }; export type DownloadBundleError = DownloadBundleErrors[keyof DownloadBundleErrors]; @@ -916,17 +968,21 @@ export type CheckoutErrors = { */ 400: ApiError; /** - * Missing or invalid token + * Missing or invalid token (`unauthenticated`, `unsupported_token`, `invalid_token`), or GitHub sign-in has ended (`github_auth_retired`) */ 401: ApiError; /** - * Not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`) + * GitHub: not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`). Entra: a guest account (`guest_not_allowed`) */ 403: ApiError; /** * Unknown asset, version, or file */ 404: ApiError; + /** + * GitHub or Entra could not be reached to validate the token (`github_unavailable`, `entra_unavailable`) + */ + 502: ApiError; }; export type CheckoutError = CheckoutErrors[keyof CheckoutErrors]; @@ -953,17 +1009,25 @@ export type PublishPlanErrors = { */ 400: ApiError; /** - * Missing or invalid token + * Missing or invalid token (`unauthenticated`, `unsupported_token`, `invalid_token`), or GitHub sign-in has ended (`github_auth_retired`) */ 401: ApiError; /** - * Not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`) + * GitHub: not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`). Entra: a guest account (`guest_not_allowed`) */ 403: ApiError; /** * `version_not_bumped`, `version_exists`, or `branch_exists` */ 409: ApiError; + /** + * Entra caller but the API's GitHub publish token is missing or rejected (`server_misconfigured`) + */ + 500: ApiError; + /** + * GitHub unreachable (`github_unavailable`, `registry_unavailable`) + */ + 502: ApiError; }; export type PublishPlanError = PublishPlanErrors[keyof PublishPlanErrors]; @@ -990,17 +1054,25 @@ export type PublishErrors = { */ 400: ApiError; /** - * Missing or invalid token + * Missing or invalid token (`unauthenticated`, `unsupported_token`, `invalid_token`), or GitHub sign-in has ended (`github_auth_retired`) */ 401: ApiError; /** - * Not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`) + * GitHub: not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`). Entra: a guest account (`guest_not_allowed`) */ 403: ApiError; /** * `version_not_bumped`, `version_exists`, or `branch_exists` */ 409: ApiError; + /** + * Entra caller but the API's GitHub publish token is missing or rejected (`server_misconfigured`) + */ + 500: ApiError; + /** + * GitHub unreachable (`github_unavailable`, `registry_unavailable`) + */ + 502: ApiError; }; export type PublishError = PublishErrors[keyof PublishErrors]; diff --git a/src/lib/entra.ts b/src/lib/entra.ts new file mode 100644 index 0000000..c8da767 --- /dev/null +++ b/src/lib/entra.ts @@ -0,0 +1,245 @@ +/** + * Microsoft Entra sign-in for the web app, over `@azure/msal-browser` v5. + * + * The MSAL instance sits behind the small {@link EntraClient} interface so the + * session provider and its tests never touch the package directly: production + * uses {@link createEntraClient}, tests inject {@link createFakeEntraClient}. + * + * Flow: auth-code + PKCE via `loginRedirect`. Entra returns to the redirect + * bridge page (`auth-redirect.html`), which hands the response to MSAL and + * navigates back to the route that started sign-in; `handleRedirectPromise()` + * on that load completes the sign-in. Tokens live in sessionStorage (per tab). + */ + +import { + type AccountInfo, + AuthError, + BrowserAuthError, + BrowserAuthErrorCodes, + ClientAuthError, + ClientAuthErrorCodes, + InteractionRequiredAuthError, + PublicClientApplication, + ServerError, +} from '@azure/msal-browser'; + +/** The Emergent Software tenant. */ +export const ENTRA_TENANT_ID = '25ee13ae-a8a5-4bc2-bb23-aea90536fb0c'; +export const ENTRA_AUTHORITY = `https://login.microsoftonline.com/${ENTRA_TENANT_ID}`; +/** The `ES ATK Web` SPA app registration (shared by dev and prod). */ +export const ENTRA_CLIENT_ID = '07a23158-19c4-4180-a2a9-41cb80882a65'; +/** The `ES ATK API` scope; MSAL adds `openid profile offline_access` itself. */ +export const ENTRA_SCOPES = ['api://8da5aa72-0565-4ae8-bf5a-db89d8bd3186/access_as_user']; + +/** The signed-in Entra account, as much of MSAL's `AccountInfo` as the app needs. */ +export interface EntraAccount { + /** MSAL's stable per-account key; used as the `/me` query-key segment. */ + homeAccountId: string; + name?: string; + /** The user principal name (`jasonp@emergentsoftware.net`). */ + username: string; +} + +/** What the session provider needs from MSAL. */ +export interface EntraClient { + /** Silently get an API access token for the active account; never interactive. */ + acquireToken(): Promise; + /** Drop the active account and its tokens without a redirect (expired session). */ + clearAccount(): Promise; + /** The active account in this tab, or null. */ + getAccount(): EntraAccount | null; + /** + * Complete a redirect return. Resolves to the signed-in account, or null + * when this page load is not a return from Entra. Rejects when the sign-in + * failed (cancelled, `redirect_uri_mismatch`, network). + */ + handleRedirect(): Promise; + /** Must resolve before any other method is called. */ + initialize(): Promise; + /** Start the redirect sign-in; the app is re-entered at `#`. */ + login(returnPath: string): Promise; + /** Sign out of Entra and land on the app root, signed out. */ + logout(): Promise; +} + +export type FakeEntraCall = + | { method: 'acquireToken' | 'clearAccount' | 'handleRedirect' | 'initialize' | 'logout' } + | { method: 'login'; returnPath: string }; + +/** Scripted stand-in for tests. Mutate `state` to drive the next call's outcome. */ +export interface FakeEntraClient extends EntraClient { + calls: FakeEntraCall[]; + state: FakeEntraState; +} + +export interface FakeEntraState { + /** What `getAccount()` returns, and what `handleRedirect()` installs on success. */ + account: EntraAccount | null; + /** When set, `handleRedirect()` rejects with it. */ + redirectError?: unknown; + /** When set, `handleRedirect()` resolves with it (and installs it as `account`). */ + redirectResult?: EntraAccount | null; + /** What `acquireToken()` resolves with. */ + token: string; + /** When set, `acquireToken()` rejects with it. */ + tokenError?: unknown; +} + +/** The app root (`origin` + Vite `base`), always with a trailing slash. A registered redirect URI. */ +export function appRootUrl(): string { + const base = import.meta.env.BASE_URL || '/'; + return `${window.location.origin}${base.endsWith('/') ? base : `${base}/`}`; +} + +/** The {@link EntraClient} used in production. */ +export function createEntraClient(): EntraClient { + const pca = new PublicClientApplication({ + auth: { + authority: ENTRA_AUTHORITY, + clientId: ENTRA_CLIENT_ID, + postLogoutRedirectUri: appRootUrl(), + redirectUri: redirectBridgeUrl(), + }, + cache: { + cacheLocation: 'sessionStorage', + }, + }); + + const activeAccount = (): AccountInfo | null => { + const active = pca.getActiveAccount(); + if (active) return active; + const first = pca.getAllAccounts()[0] ?? null; + if (first) pca.setActiveAccount(first); + return first; + }; + + return { + async acquireToken() { + // With no account MSAL throws `no_account_error`, which counts as an expired session. + const result = await pca.acquireTokenSilent({ account: activeAccount() ?? undefined, scopes: ENTRA_SCOPES }); + return result.accessToken; + }, + async clearAccount() { + const account = activeAccount(); + await pca.clearCache({ account }); + pca.setActiveAccount(null); + }, + getAccount() { + const account = activeAccount(); + return account ? toEntraAccount(account) : null; + }, + async handleRedirect() { + const result = await pca.handleRedirectPromise(); + scrubRedirectState(); + if (!result?.account) return null; + pca.setActiveAccount(result.account); + return toEntraAccount(result.account); + }, + initialize: () => pca.initialize(), + login(returnPath) { + return pca.loginRedirect({ + redirectStartPage: `${appRootUrl()}#${returnPath}`, + scopes: ENTRA_SCOPES, + }); + }, + logout() { + return pca.logoutRedirect({ + account: activeAccount() ?? undefined, + postLogoutRedirectUri: appRootUrl(), + }); + }, + }; +} + +/** A scripted {@link EntraClient} for tests. */ +export function createFakeEntraClient(seed: Partial = {}): FakeEntraClient { + const state: FakeEntraState = { account: null, token: 'entra-access-token', ...seed }; + const calls: FakeEntraCall[] = []; + return { + async acquireToken() { + calls.push({ method: 'acquireToken' }); + if (state.tokenError !== undefined) throw state.tokenError; + return state.token; + }, + calls, + async clearAccount() { + calls.push({ method: 'clearAccount' }); + state.account = null; + }, + getAccount() { + return state.account; + }, + async handleRedirect() { + calls.push({ method: 'handleRedirect' }); + if (state.redirectError !== undefined) throw state.redirectError; + if (state.redirectResult) state.account = state.redirectResult; + return state.redirectResult ?? null; + }, + async initialize() { + calls.push({ method: 'initialize' }); + }, + async login(returnPath) { + calls.push({ method: 'login', returnPath }); + }, + async logout() { + calls.push({ method: 'logout' }); + state.account = null; + }, + state, + }; +} + +/** + * A user-facing description of a failed Entra sign-in. Server errors carry + * Entra's own description (e.g. the user cancelled); MSAL's library errors + * only carry a hashed pointer, so those fall back to the error code. + */ +export function describeEntraError(error: unknown): string { + if (error instanceof AuthError) { + const message = error.errorMessage && !error.errorMessage.startsWith('See https://') ? error.errorMessage : ''; + return message + ? `Sign-in did not complete (${error.errorCode}): ${message}` + : `Sign-in did not complete (${error.errorCode}).`; + } + if (error instanceof Error) return `Sign-in did not complete: ${error.message}`; + return 'Sign-in did not complete.'; +} + +/** + * True when a silent token request failed because the Entra session is over + * and only a new interactive sign-in can fix it. Anything else (network, + * timeouts, an interaction already in progress) is transient. + */ +export function isSessionExpiredError(error: unknown): boolean { + if (error instanceof InteractionRequiredAuthError) return true; + if (error instanceof ServerError) return error.errorCode === 'invalid_grant'; + if (error instanceof BrowserAuthError) return error.errorCode === BrowserAuthErrorCodes.noAccountError; + if (error instanceof ClientAuthError) return error.errorCode === ClientAuthErrorCodes.noAccountFound; + return false; +} + +/** The redirect bridge page, `auth-redirect.html`. A registered redirect URI. */ +export function redirectBridgeUrl(): string { + return `${appRootUrl()}auth-redirect.html`; +} + +/** + * Drop the `?state=…` MSAL appends to the post-logout redirect (the app root) + * so the landing URL is clean. Keeps every other query parameter and the hash. + */ +export function scrubRedirectState(): void { + const params = new URLSearchParams(window.location.search); + if (!params.has('state')) return; + params.delete('state'); + const query = params.toString(); + const url = `${window.location.pathname}${query ? `?${query}` : ''}${window.location.hash}`; + window.history.replaceState(window.history.state, '', url); +} + +function toEntraAccount(account: AccountInfo): EntraAccount { + return { + homeAccountId: account.homeAccountId, + ...(account.name ? { name: account.name } : {}), + username: account.username, + }; +} diff --git a/src/lib/publish-errors.ts b/src/lib/publish-errors.ts index 8045168..8b3b0ba 100644 --- a/src/lib/publish-errors.ts +++ b/src/lib/publish-errors.ts @@ -31,7 +31,7 @@ export class PublishPermissionError extends PublishError { constructor(params: { cause?: unknown; detail?: string }) { super( `Insufficient permissions to publish: ${params.detail ?? 'unknown'}`, - 'Your GitHub session does not allow publishing. Sign out and sign back in, making sure you are an active member of the EmergentSoftware organization.', + 'Your session does not allow publishing. Sign out and sign back in with your Emergent account, or with a GitHub account that is an active member of the EmergentSoftware organization.', { cause: params.cause }, ); this.name = 'PublishPermissionError'; diff --git a/src/lib/publish-service.ts b/src/lib/publish-service.ts index d8b9139..298a618 100644 --- a/src/lib/publish-service.ts +++ b/src/lib/publish-service.ts @@ -66,9 +66,11 @@ export const DRY_RUN_PR_URL_MARKER = 'https://dry-run.local/atk/contribute/previ /** * Publish a prepared contribution through the ATK API, which validates the - * payload and opens a pull request against the registry with the signed-in - * user's own token (so the PR is authored by them). This mirrors the CLI's - * `atk publish` flow. + * payload and opens a pull request against the registry: with the signed-in + * user's own token for a GitHub session (so the PR is authored by them), or + * with the API's publish identity for an Entra session (the commit is authored + * as the user and the PR body names them). This mirrors the CLI's `atk publish` + * flow. * * When `dryRun` is true the payload is sent to `POST /publish/plan` instead: * the API validates it and returns the plan without touching GitHub, and the diff --git a/src/lib/session.ts b/src/lib/session.ts index d57afc4..c95db69 100644 --- a/src/lib/session.ts +++ b/src/lib/session.ts @@ -1,4 +1,10 @@ -/** Constants, types, and helpers for the GitHub OAuth session layer. */ +/** + * Constants, types, and helpers for the session layer: the shared types for + * both sign-in providers, plus the GitHub OAuth helpers. The Entra side lives + * in `entra.ts`. + */ + +import type { Principal } from './api/types.gen'; import { authGitHubExchange } from './api'; import { ApiRequestError, createApiClient, unwrap } from './api-client'; @@ -6,6 +12,7 @@ import { ApiRequestError, createApiClient, unwrap } from './api-client'; export const SESSION_STORAGE_KEYS = { oauthState: 'atk:session:oauth-state', pendingReturn: 'atk:session:pending-return', + sunsetDismissed: 'atk:session:sunset-dismissed', token: 'atk:session:token', } as const; @@ -18,9 +25,28 @@ export interface OAuthStateRecord { state: string; } +/** A notice the signed-out landing shows above the sign-in buttons. */ +export interface SessionNotice { + kind: SessionNoticeKind; + message: string; +} + +/** + * - `entra_expired`: the Entra session could not be renewed silently. + * - `github_auth_retired`: the API refused the GitHub token because GitHub sign-in has ended (the API's message, verbatim). + * - `sign_in_failed`: the Entra redirect came back with an error (cancelled, misconfigured, network). + */ +export type SessionNoticeKind = 'entra_expired' | 'github_auth_retired' | 'sign_in_failed'; + +/** Which provider the current session came from. */ +export type SessionScheme = 'entra' | 'github'; + /** Session status machine. */ export type SessionStatus = 'authenticating' | 'member' | 'non-member' | 'signed-out' | 'verifying'; +/** The signed-in user: the `GET /me` principal. `login` is the GitHub login or the Entra UPN. */ +export type SessionUser = Principal; + /** Build the GitHub authorize URL for an OAuth redirect. */ export function buildAuthorizeUrl(params: { clientId: string; @@ -36,6 +62,11 @@ export function buildAuthorizeUrl(params: { return url.toString(); } +/** Forget a GitHub authorize redirect that never came back. */ +export function clearOAuthState(): void { + window.sessionStorage.removeItem(SESSION_STORAGE_KEYS.oauthState); +} + /** Clear the access token from sessionStorage. */ export function clearToken(): void { window.sessionStorage.removeItem(SESSION_STORAGE_KEYS.token); @@ -71,6 +102,16 @@ export function consumePendingReturnPath(): string | undefined { return value ?? undefined; } +/** + * The manifest `author` pre-fill for a signed-in user: the display name for + * an Entra user (falling back to the UPN), the login for a GitHub user. + */ +export function defaultAuthorFor(scheme: null | SessionScheme, user: null | SessionUser): string { + if (!user) return ''; + if (scheme === 'entra') return user.name || user.login; + return user.login; +} + /** * Default redirect URI for the callback route. * @@ -119,6 +160,12 @@ export function fingerprintToken(token: string): string { return `${token.slice(0, 4)}:${token.slice(-4)}:${token.length}`; } +/** The calendar date (UTC, `YYYY-MM-DD`) of an RFC 3339 timestamp such as `githubAuthSunset`. */ +export function formatSunsetDate(timestamp: string): string { + const date = new Date(timestamp); + return Number.isNaN(date.getTime()) ? timestamp : date.toISOString().slice(0, 10); +} + /** Generate a cryptographically random state value (32 bytes → 64 hex chars). */ export function generateOAuthState(): string { const bytes = new Uint8Array(32); diff --git a/src/providers/SessionProvider.tsx b/src/providers/SessionProvider.tsx index 6127653..9102707 100644 --- a/src/providers/SessionProvider.tsx +++ b/src/providers/SessionProvider.tsx @@ -1,11 +1,21 @@ import { useQuery } from '@tanstack/react-query'; -import { createContext, type ReactNode, useCallback, useEffect, useMemo, useState } from 'react'; +import { createContext, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { me } from '@/lib/api'; -import { type ApiClient, createApiClient, isApiError, unwrap } from '@/lib/api-client'; +import { + type ApiClient, + type ApiCredential, + createApiClient, + isApiError, + SESSION_EXPIRED_CODE, + sessionExpiredError, + unwrap, +} from '@/lib/api-client'; +import { describeEntraError, type EntraAccount, type EntraClient, isSessionExpiredError } from '@/lib/entra'; import { queryKeys } from '@/lib/query-keys'; import { buildAuthorizeUrl, + clearOAuthState, clearToken, defaultRedirectUri, fingerprintToken, @@ -13,55 +23,142 @@ import { getClientId, OAUTH_SCOPES, readToken, + type SessionNotice, + type SessionScheme, type SessionStatus, + type SessionUser, writeOAuthState, writeToken, } from '@/lib/session'; export interface SessionContextValue { - /** ATK API client configured with the session token; null when signed out. */ + /** ATK API client configured with the session credential; null when signed out. */ api: ApiClient | null; - /** Called by the AuthCallback route after a successful code exchange. */ + /** Called by the AuthCallback route after a successful GitHub code exchange. */ completeSignIn: (token: string) => void; - signIn: (returnPath?: string) => void; + dismissNotice: () => void; + /** Why the last session ended, shown on the signed-out landing. */ + notice: null | SessionNotice; + /** Which provider the current session came from; null when signed out. */ + scheme: null | SessionScheme; + /** Start a sign-in. Either provider first clears the other's state. */ + signIn: (provider: SessionScheme, returnPath?: string) => void; signOut: () => void; status: SessionStatus; - token: null | string; user: null | SessionUser; } -export interface SessionUser { - login: string; - name: null | string; -} - export const SessionContext = createContext(null); interface SessionProviderProps { children: ReactNode; + /** The Entra (MSAL) client; production passes `createEntraClient()`, tests a fake. */ + entraClient: EntraClient; } -export function SessionProvider({ children }: SessionProviderProps) { - const [token, setToken] = useState(() => readToken()); +const ENTRA_EXPIRED_MESSAGE = 'Your Emergent sign-in has expired. Sign in again.'; + +/** + * One session, two providers. + * + * - GitHub: the token from the OAuth callback lives in sessionStorage. + * - Entra: MSAL owns the account and tokens (also sessionStorage); the API + * client asks it for an access token on every request. + * + * Startup initializes MSAL and completes a redirect return before the status + * is anything other than `verifying`, so no route sees the redirect in flight. + * `GET /me` then identifies the caller and enforces membership for both + * schemes: 2xx is `member`; 403 `not_org_member` / `org_membership_unverifiable` + * / `guest_not_allowed` is `non-member`; 401 ends the session. + */ +export function SessionProvider({ children, entraClient }: SessionProviderProps) { + const [githubToken, setGithubToken] = useState(() => readToken()); + const [entraAccount, setEntraAccount] = useState(null); + const [entraReady, setEntraReady] = useState(false); const [isAuthenticating, setIsAuthenticating] = useState(false); + const [notice, setNotice] = useState(null); + const startedRef = useRef(false); + + const scheme: null | SessionScheme = entraAccount ? 'entra' : githubToken ? 'github' : null; + + const dropEntraAccount = useCallback(() => { + setEntraAccount(null); + entraClient.clearAccount().catch((error: unknown) => { + console.warn('[SessionProvider] Could not clear the Entra account:', error); + }); + }, [entraClient]); + + const dropGithubToken = useCallback(() => { + clearToken(); + clearOAuthState(); + setGithubToken(null); + }, []); + + // Startup: initialize MSAL, then complete a redirect return if this load is one. + useEffect(() => { + if (startedRef.current) return; + startedRef.current = true; + + (async () => { + try { + await entraClient.initialize(); + const returned = await entraClient.handleRedirect(); + const account = returned ?? entraClient.getAccount(); + if (account) { + // One scheme at a time: an Entra account outranks a leftover GitHub token. + if (readToken()) dropGithubToken(); + setEntraAccount(account); + } + } catch (error) { + setNotice({ kind: 'sign_in_failed', message: describeEntraError(error) }); + } finally { + setEntraReady(true); + } + })(); + }, [dropGithubToken, entraClient]); + + const expireEntraSession = useCallback(() => { + setNotice({ kind: 'entra_expired', message: ENTRA_EXPIRED_MESSAGE }); + dropEntraAccount(); + }, [dropEntraAccount]); + + const credential = useMemo(() => { + if (entraAccount) { + return { + getToken: async () => { + try { + return await entraClient.acquireToken(); + } catch (error) { + if (isSessionExpiredError(error)) { + expireEntraSession(); + throw sessionExpiredError(); + } + throw error; + } + }, + scheme: 'entra', + }; + } + if (githubToken) return { scheme: 'github', token: githubToken }; + return null; + }, [entraAccount, entraClient, expireEntraSession, githubToken]); + + const api = useMemo(() => (credential ? createApiClient(credential) : null), [credential]); - const api = useMemo(() => (token ? createApiClient(token) : null), [token]); + const queryKey = entraAccount + ? queryKeys.session.user(`entra:${entraAccount.homeAccountId}`) + : githubToken + ? queryKeys.session.user(`github:${fingerprintToken(githubToken)}`) + : queryKeys.session.user('none'); - // `GET /me` both identifies the caller and enforces EmergentSoftware - // membership: a 2xx means "active member"; 401 means the token is dead; - // 403 `not_org_member` / `org_membership_unverifiable` means "signed in, but - // not allowed in". const verifyQuery = useQuery({ - enabled: Boolean(token && api), + // Nothing is sent until startup has settled which scheme (if any) is active. + enabled: entraReady && Boolean(credential && api), queryFn: async ({ signal }) => { if (!api) throw new Error('API client not initialized'); - const principal = unwrap(await me({ client: api, signal }), 'your GitHub account'); - return { - login: principal.login, - name: principal.name ?? null, - }; + return unwrap(await me({ client: api, signal }), 'your account'); }, - queryKey: token ? queryKeys.session.user(fingerprintToken(token)) : ['session', 'user', 'none'], + queryKey, retry: false, staleTime: 5 * 60 * 1000, }); @@ -80,20 +177,26 @@ export function SessionProvider({ children }: SessionProviderProps) { message: verifyError.message, status: verifyError.status, }); - } else if (!isApiError(verifyError, 'not_org_member') && !tokenRejected) { + } else if ( + !isApiError(verifyError, 'not_org_member') && + !isApiError(verifyError, 'guest_not_allowed') && + !tokenRejected + ) { console.warn('[SessionProvider] Session verification failed:', verifyError); } }, [tokenRejected, verifyError]); const status = useMemo(() => { if (isAuthenticating) return 'authenticating'; - if (!token) return 'signed-out'; + if (!entraReady) return 'verifying'; + if (!credential) return 'signed-out'; if (verifyQuery.isPending || verifyQuery.isFetching) return 'verifying'; if (verifyQuery.isError) return tokenRejected ? 'signed-out' : 'non-member'; return verifyQuery.data ? 'member' : 'non-member'; }, [ + credential, + entraReady, isAuthenticating, - token, tokenRejected, verifyQuery.data, verifyQuery.isError, @@ -101,56 +204,95 @@ export function SessionProvider({ children }: SessionProviderProps) { verifyQuery.isPending, ]); - const signIn = useCallback((returnPath?: string) => { - // HashRouter URL after the leading '#'. Fallback to '/'. - const currentHash = window.location.hash.startsWith('#') ? window.location.hash.slice(1) : ''; - const resolvedReturn = returnPath ?? (currentHash || '/'); - const stateValue = generateOAuthState(); - writeOAuthState({ returnPath: resolvedReturn, state: stateValue }); - setIsAuthenticating(true); - const authorizeUrl = buildAuthorizeUrl({ - clientId: getClientId(), - redirectUri: defaultRedirectUri(), - scopes: OAUTH_SCOPES, - state: stateValue, - }); - window.location.assign(authorizeUrl); - }, []); + const signIn = useCallback( + (provider: SessionScheme, returnPath?: string) => { + // HashRouter URL after the leading '#'. Fallback to '/'. + const currentHash = window.location.hash.startsWith('#') ? window.location.hash.slice(1) : ''; + const resolvedReturn = returnPath ?? (currentHash || '/'); + setNotice(null); + setIsAuthenticating(true); + + if (provider === 'entra') { + if (githubToken) dropGithubToken(); + entraClient.login(resolvedReturn).catch((error: unknown) => { + setNotice({ kind: 'sign_in_failed', message: describeEntraError(error) }); + setIsAuthenticating(false); + }); + return; + } + + if (entraAccount) dropEntraAccount(); + const stateValue = generateOAuthState(); + writeOAuthState({ returnPath: resolvedReturn, state: stateValue }); + const authorizeUrl = buildAuthorizeUrl({ + clientId: getClientId(), + redirectUri: defaultRedirectUri(), + scopes: OAUTH_SCOPES, + state: stateValue, + }); + window.location.assign(authorizeUrl); + }, + [dropEntraAccount, dropGithubToken, entraAccount, entraClient, githubToken], + ); const completeSignIn = useCallback((newToken: string) => { writeToken(newToken); - setToken(newToken); + setGithubToken(newToken); setIsAuthenticating(false); }, []); const signOut = useCallback(() => { - clearToken(); - setToken(null); + setNotice(null); setIsAuthenticating(false); - }, []); + if (entraAccount) { + // Ends the Entra web session too; lands back on the app root, signed out. + setEntraAccount(null); + entraClient.logout().catch((error: unknown) => { + console.warn('[SessionProvider] Entra sign-out redirect failed; clearing the local session:', error); + dropEntraAccount(); + }); + return; + } + dropGithubToken(); + }, [dropEntraAccount, dropGithubToken, entraAccount, entraClient]); - // A 401 from the API means the stored token is dead: drop it so the app - // returns to the signed-out landing instead of retrying forever. + // A 401 from `/me` means the credential is dead: drop it so the app returns + // to the signed-out landing instead of retrying forever. `github_auth_retired` + // keeps the API's message; an expired Entra session set its own notice. useEffect(() => { - if (tokenRejected) signOut(); - }, [signOut, tokenRejected]); + if (!tokenRejected || !verifyError) return; + const message: string = verifyError.message; + const retired = isApiError(verifyError, 'github_auth_retired'); + const expired = isApiError(verifyError, SESSION_EXPIRED_CODE); + if (retired) { + setNotice({ kind: 'github_auth_retired', message }); + } else if (entraAccount && !expired) { + setNotice({ kind: 'sign_in_failed', message: `Your Emergent sign-in was refused: ${message}` }); + } + if (entraAccount) dropEntraAccount(); + else dropGithubToken(); + }, [dropEntraAccount, dropGithubToken, entraAccount, tokenRejected, verifyError]); - // Keep isAuthenticating in sync if the user returns to the tab with an existing token. + // Keep isAuthenticating in sync if the user returns to the tab with a session. useEffect(() => { - if (token) setIsAuthenticating(false); - }, [token]); + if (scheme) setIsAuthenticating(false); + }, [scheme]); + + const dismissNotice = useCallback(() => setNotice(null), []); const value = useMemo( () => ({ api, completeSignIn, + dismissNotice, + notice, + scheme, signIn, signOut, status, - token, user: verifyQuery.data ?? null, }), - [api, completeSignIn, signIn, signOut, status, token, verifyQuery.data], + [api, completeSignIn, dismissNotice, notice, scheme, signIn, signOut, status, verifyQuery.data], ); return {children}; diff --git a/src/routes/Contribute.tsx b/src/routes/Contribute.tsx index 137def6..daac630 100644 --- a/src/routes/Contribute.tsx +++ b/src/routes/Contribute.tsx @@ -27,6 +27,7 @@ import { publishContribution, type PublishProgressEvent } from '@/lib/publish-se import { fetchRegistry, findExistingAsset } from '@/lib/registry-client'; import { AssetType, type Manifest, ManifestSchema } from '@/lib/schemas/manifest'; import { type Registry } from '@/lib/schemas/registry'; +import { defaultAuthorFor } from '@/lib/session'; import { extractSkillArchive, hasSkillExtension } from '@/lib/skill-archive'; import { type BumpType, bumpVersion } from '@/lib/version-utils'; @@ -173,11 +174,11 @@ export function computeVersionConflict(draft: DraftState, registry: null | Regis export function ContributeRoute() { useWideLayout(); - const { api, user } = useSession(); + const { api, scheme, user } = useSession(); const toast = useToast(); const navigate = useNavigate(); const location = useLocation(); - const defaultAuthor = user?.login ?? ''; + const defaultAuthor = defaultAuthorFor(scheme, user); const [draft, setDraft] = useState(() => createInitialDraft(defaultAuthor)); const [submitting, setSubmitting] = useState(false); const [progress, setProgress] = useState(null); @@ -913,7 +914,7 @@ function StepMetadata({ draft, onChange }: StepProps) { placeholder='GitHub login' value={draft.author} /> -

Pre-filled from your GitHub session; edit if needed.

+

Pre-filled from your sign-in; edit if needed.

diff --git a/src/routes/CreateBundle.tsx b/src/routes/CreateBundle.tsx index 411e838..778e589 100644 --- a/src/routes/CreateBundle.tsx +++ b/src/routes/CreateBundle.tsx @@ -27,6 +27,7 @@ import { findExistingBundle } from '@/lib/registry-client'; import { type Bundle, BundleSchema } from '@/lib/schemas/bundle'; import { AssetType } from '@/lib/schemas/manifest'; import { type Registry, type RegistryAsset } from '@/lib/schemas/registry'; +import { defaultAuthorFor } from '@/lib/session'; import { type BumpType, bumpVersion } from '@/lib/version-utils'; export const DRAFT_STORAGE_KEY = 'atk:bundle:draft'; @@ -172,7 +173,7 @@ export function computeBundleVersionConflict(draft: BundleDraftState, registry: export function CreateBundleRoute() { useWideLayout(); - const { api, user } = useSession(); + const { api, scheme, user } = useSession(); const toast = useToast(); const navigate = useNavigate(); const location = useLocation(); @@ -180,7 +181,7 @@ export function CreateBundleRoute() { const registry = registryQuery.data ?? null; const seed = (location.state ?? null) as CreateBundleSeed | null; - const defaultAuthor = user?.login ?? ''; + const defaultAuthor = defaultAuthorFor(scheme, user); const [draft, setDraft] = useState(() => createInitialBundleDraft(defaultAuthor)); const [submitting, setSubmitting] = useState(false); @@ -729,7 +730,7 @@ function StepMetadata({ draft, onChange }: StepProps) { placeholder='GitHub login' value={draft.author} /> -

Pre-filled from your GitHub session; edit if needed.

+

Pre-filled from your sign-in; edit if needed.

diff --git a/src/routes/NotAuthorized.tsx b/src/routes/NotAuthorized.tsx index 4e24549..f66d1c4 100644 --- a/src/routes/NotAuthorized.tsx +++ b/src/routes/NotAuthorized.tsx @@ -3,7 +3,36 @@ import { Button } from '@/components/ui/button'; import { useSession } from '@/hooks/useSession'; export function NotAuthorizedRoute() { - const { signOut, user } = useSession(); + const { scheme, signOut, user } = useSession(); + + if (scheme === 'entra') { + return ( + <> + +
+ {user ? ( +

+ You are signed in as {user.login}, but this account does not have access to the + Emergent Software registry. +

+ ) : null} +

+ ATK is available to Emergent Software staff; sign out and use your Emergent account, or sign in with + GitHub. +

+
+ +
+
+ + ); + } return ( <> @@ -18,20 +47,19 @@ export function NotAuthorizedRoute() { > {user ? (

- You are signed in as {user.login}, but this account does not - have access to the Emergent Software registry. + You are signed in as {user.login}, but this account does not have access to the Emergent + Software registry.

) : null}

- The Agentic Toolkit registry is gated on membership of the{' '} - EmergentSoftware GitHub organization. If you believe you should - have access: + The Agentic Toolkit registry is gated on membership of the EmergentSoftware GitHub + organization. If you believe you should have access:

  • Ask a repo admin to add your GitHub account to the org.
  • - Make sure your org membership is set to public, or grant the{' '} - read:org scope when signing in. + Make sure your org membership is set to public, or grant the read:org scope when + signing in.
  • Sign out and retry with a different account.
diff --git a/src/routes/SignIn.tsx b/src/routes/SignIn.tsx index eccdc73..02e8c86 100644 --- a/src/routes/SignIn.tsx +++ b/src/routes/SignIn.tsx @@ -1,42 +1,43 @@ import { useEffect } from 'react'; -import { Navigate } from 'react-router'; +import { Navigate, useSearchParams } from 'react-router'; import { LoadingIndicator } from '@/components/LoadingIndicator'; import { PageHeader } from '@/components/PageHeader'; import { Button } from '@/components/ui/button'; import { useSession } from '@/hooks/useSession'; -import { consumePendingReturnPath } from '@/lib/session'; +import { consumePendingReturnPath, type SessionScheme } from '@/lib/session'; /** - * Alternate direct-entry sign-in route. Kicks off the OAuth redirect immediately; - * if the user is already signed in and a member, forwards them home. + * Alternate direct-entry sign-in route. Kicks off the redirect immediately: + * Entra by default, GitHub with `?provider=github`. If the user is already + * signed in and a member, forwards them home. + * + * The query is read through react-router (hash-aware): under `HashRouter` the + * params live inside the fragment (`#/sign-in?provider=github`), where nuqs's + * react-router adapter, which reads `window.location.search`, cannot see them. */ export function SignInRoute() { const { signIn, status } = useSession(); + const [searchParams] = useSearchParams(); + const provider: SessionScheme = searchParams.get('provider') === 'github' ? 'github' : 'entra'; + const providerLabel = provider === 'github' ? 'GitHub' : 'Microsoft'; useEffect(() => { if (status === 'signed-out') { - signIn(consumePendingReturnPath()); + signIn(provider, consumePendingReturnPath()); } - }, [signIn, status]); + }, [provider, signIn, status]); if (status === 'member') return ; if (status === 'non-member') return ; return ( <> - +
- +
-
diff --git a/vite.config.ts b/vite.config.ts index 8f746b1..c2a1339 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -6,6 +6,15 @@ import { defineConfig } from 'vite'; export default defineConfig({ base: '/agentic-toolkit-web/', + build: { + rollupOptions: { + // Two entries: the SPA and the MSAL redirect bridge page (served at auth-redirect.html). + input: { + main: path.resolve(import.meta.dirname, 'index.html'), + redirect: path.resolve(import.meta.dirname, 'auth-redirect.html'), + }, + }, + }, plugins: [react(), tailwindcss()], resolve: { alias: {