feat: add vue-spa browser-only SPA login example - #8
Merged
Conversation
- Add a Vue 3 + Vite + TypeScript SPA that runs Authorization Code + PKCE entirely in the browser via oidc-client-ts, as a public client with no backend and no client secret - Mint an explicit nonce on sign-in, since oidc-client-ts only sends and verifies one when the caller supplies it - Keep both the token store and the PKCE state store in sessionStorage, as the state store otherwise defaults to localStorage - Revoke the refresh token before the access token on sign-out, so a failure on the first revoke cannot leave the refresh token alive at Signet - Guard renewal on the presence of a refresh token and make it single-flight, avoiding a hidden-iframe fallback that hangs and a rotation replay - Derive the signed-in user from UserManager events so the UI cannot drift out of sync with the stored session - Proxy /api to go-webservice through the Vite dev server, leaving that example untouched - Document the CORS and public-client setup, the sessionStorage XSS trade-off, and that the ID token signature is not verified
There was a problem hiding this comment.
Pull request overview
Adds a new vue-spa/ example demonstrating a browser-only Authorization Code + PKCE (public client) login flow against Signet using oidc-client-ts, plus a root README.md update to document and link the new example.
Changes:
- Introduces a Vue 3 + Vite + TypeScript SPA example with auth module (
userManager+useAuth), router guard, and minimal views (Login / Callback / Profile). - Adds a small API client with Bearer auth and single 401→silent-renew retry, along with Vitest unit coverage for the auth/client behavior.
- Updates root documentation to include the new TypeScript/Bun-based SPA example in the quick reference and adds a dedicated section.
Reviewed changes
Copilot reviewed 20 out of 21 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| vue-spa/vite.config.ts | Vite dev/preview configuration (ports, proxy, test env pinning). |
| vue-spa/tsconfig.node.json | TypeScript config for Vite config/typechecking in Node context. |
| vue-spa/tsconfig.json | TS project references for app + node configs. |
| vue-spa/tsconfig.app.json | Strict TS config for Vue app source. |
| vue-spa/src/views/ProfileView.vue | Profile UI: token/session display, refresh/logout actions, API call buttons. |
| vue-spa/src/views/LoginView.vue | Login UI: config checks, discovery probe, sign-in trigger and error display. |
| vue-spa/src/views/CallbackView.vue | Redirect callback handler view and error messaging. |
| vue-spa/src/router/index.ts | SPA routes and auth guard using shared signed-in predicate. |
| vue-spa/src/main.ts | Vue app bootstrap and router mounting. |
| vue-spa/src/auth/userManager.ts | oidc-client-ts UserManager settings for PKCE, sessionStorage stores, revocation order. |
| vue-spa/src/auth/useAuth.ts | Shared auth state, login/callback/logout, refresh/renew single-flight. |
| vue-spa/src/auth/useAuth.test.ts | Unit tests for settings, auth flows, renewal behavior, and API retry behavior. |
| vue-spa/src/App.vue | App shell (nav + router-view) and minimal styling. |
| vue-spa/src/api/client.ts | Bearer fetch wrapper with single 401 retry via shared renew(). |
| vue-spa/README.md | Example-specific documentation (flow, setup, security notes, routes). |
| vue-spa/package.json | Bun/Vite/Vue dependencies and scripts. |
| vue-spa/index.html | HTML entry point for the SPA. |
| vue-spa/bun.lock | Bun lockfile for the example dependencies. |
| vue-spa/.gitignore | Ignores local env, build output, and dependencies for the example. |
| vue-spa/.env.example | Documents required Vite-prefixed env vars for browser build-time config. |
| README.md | Root documentation updated to include the new SPA example. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Keep the Sign in button enabled when the discovery probe fails, since the cause is fixed outside the app and sign-in errors already surface on the page - Add a Retry discovery button that re-runs the probe - Name the untrusted TLS certificate as a likely cause alongside missing CORS
- Stay on the profile page when sign-out fails, and render the error outside the signed-in block, so a failed token revocation is actually readable - Replace a button nested in a router-link with a plain button, avoiding an invalid anchor-wrapped interactive control - Report a missing session separately from a missing refresh token, so the renewal error points at the right fix
…t collision - Stop presenting TLS as a substitute for ID token validation: OIDC Core 3.1.3.7 clause 6 allows it only for the signature, while iss, aud and exp remain mandatory, so the missing checks are a library limitation rather than a justified omission - Warn against using id_token claims for security decisions, and point to server-side validation for anything that grants access - Document that go-webservice cannot bind :8080 when Signet already holds it, including the error the Call API buttons return
…calhost - Trim whitespace from every browser-facing env value, so a stray space copied into .env fails at load instead of deep inside the OAuth exchange - Leave VITE_REDIRECT_URI blank by default so it derives from the origin the app was actually opened on, rather than hard-coding localhost and breaking anyone browsing 127.0.0.1 - Spell out that CORS and redirect-URI matching are exact, so localhost and 127.0.0.1 are not interchangeable
- Route the API base URL through the same trimming accessor as the OAuth settings, so a copy-pasted space cannot build a request URL with a leading space and fail as an opaque fetch error
appleboy
marked this pull request as ready for review
July 14, 2026 12:55
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds
vue-spa/, a Vue 3 + Vite + TypeScript example that runs Authorization Code + PKCE entirely in the browser viaoidc-client-ts— a public client with no backend and no client secret. It is the counterpart togo-oidc(server-side, confidential client), and covers the case the repo was missing: an SPA where tokens live in the browser.Warning
Opened as a draft: this has never completed a real login. Unit tests, typecheck, and build are green, but none of the plan's 5 manual end-to-end scenarios have passed against a live Signet — see Verification for the two blockers. Do not merge on the strength of the green checks alone.
Architecture / flow
flowchart TD subgraph new["vue-spa/ (all new)"] Router["router/index.ts<br/>/ · /callback · /profile<br/>+ auth guard"] Views["views/<br/>Login · Callback · Profile"] UA["auth/useAuth.ts<br/>login · callback · renew · logout<br/>user ref driven by UserManager events"] UM["auth/userManager.ts<br/>UserManager settings<br/>PKCE · nonce · sessionStorage"] API["api/client.ts<br/>Bearer fetch + single 401 retry"] Cfg["vite.config.ts<br/>proxy /api → :8080"] end Router --> Views Views --> UA Views --> API UA --> UM API --> UA API --> Cfg UM -->|"CORS: /oauth/* + /.well-known/*"| Signet[("Signet AS<br/>existing, unchanged")] Cfg --> WS["go-webservice :8080<br/>existing, NOT modified"] WS --> Signet style Router fill:#dff0d8,stroke:#3c763d style Views fill:#dff0d8,stroke:#3c763d style UA fill:#dff0d8,stroke:#3c763d style UM fill:#dff0d8,stroke:#3c763d style API fill:#dff0d8,stroke:#3c763d style Cfg fill:#dff0d8,stroke:#3c763dGreen = new in this PR.
go-webserviceand Signet are untouched; cross-origin access to the API is solved with a Vite dev proxy rather than by adding CORS to the Go example.AI Authorship
vue-spa/and the rootREADME.mdedits. No line was hand-written.Important
This is AI-authored auth code with no human line-by-line review.
src/auth/andsrc/api/client.tsneed a real read, not a spot-check.Change classification
Structurally this is a leaf: nothing in the repo imports
vue-spa/, and a bug here breaks only this example. It is classified core anyway because it is reference auth code whose entire purpose is to be copied into other people's SPAs — the propagation range is downstream consumers, not this repo. A subtly wrong OIDC wiring here gets replicated, which is exactly the failure mode the stricter bar exists for.Plan reference
plan.md(working doc, intentionally not committed). Goal and scope:go-oidcweb example is a server-side flow and can't demonstrate "no backend, tokens in the browser".vue-spa/directory + rootREADME.md(Quick Reference row + a section).go-webservice/main.gomust not gain CORS; cross-origin is solved with a Vite proxy. Also Signet server itself, and the goreleaser config.vue,vue-router,oidc-client-ts); no UI framework; no client secret anywhere; tokens insessionStorage;monitorSession: false; nosignoutRedirect().✅
must not modifyboundary respected — verified: 0 files outsidevue-spa/andREADME.mdare touched.Verification
bun run test)bun run typecheck(vue-tsc) andbun run buildpass.buildnow typechecks before bundling.⛔ Manual verification is INCOMPLETE — two live blockers
None of the plan's 5 manual scenarios (SSO happy path, unregistered redirect URI, insufficient scope, refresh rotation, revoke-on-logout) have been run to completion, because:
Failed to fetchonhttps://signet.local:8080/.well-known/openid-configuration.Failed to fetchis ambiguous — it covers a CORS block and an untrusted TLS cert. Signet's CORS support is confirmed present in source (config.go:714,router.go:60,middleware/cors.go), so the open question is whether that instance was actually started withCORS_ENABLED=true, or whether the browser is rejecting a self-signed cert forsignet.local.*:8080over HTTPS, butgo-webservice/main.go:75hard-codes:8080plain HTTP — sogo-webservicecannot bind, and the Vite proxy's plaintext request lands on Signet's TLS listener, producing400 Client sent an HTTP request to an HTTPS server. This is a port collision the plan did not anticipate; resolving it needs a decision (move Signet off:8080, or givego-webserviceaPORTenv var — which is currently on the must-not-modify list).A reviewer should treat the OAuth flow itself as unverified.
Manual steps once a Signet with CORS + a public client is available:
Signet side:
CORS_ENABLED=true,CORS_ALLOWED_ORIGINS=http://localhost:5173, a public client (no secret) with redirect URIhttp://localhost:5173/callback.Verifiability check
.env.example, README env table + routes table)Security check
.envis gitignored. Public client by construction.oidc-client-ts; see caveat below.go-webservice's job (403 path is demonstrated, not unit-tested)Security-relevant fixes already made in this commit (each was a real defect found in review, verified against
oidc-client-tssource):nonceoidc-client-tsonly sends and verifies a nonce if the caller supplies one.signinRedirect()with no args — the tutorial default — silently gives you zero replay protection.stateStoretosessionStoragelocalStorageindependently ofuserStore, so the in-flight PKCEcode_verifierwas outliving the tab and shared across tabs.refresh_tokenbeforeaccess_tokensigninSilent()silently falls back to a hidden iframe atsilent_redirect_uri(which defaults toredirect_uri=/callback), hanging 10s beforeIFrame timed out.userfromUserManagereventsNote
The ID token signature is NOT verified, and the README now says so explicitly.
oidc-client-tsnever fetches the JWKS — its JWT helper is annotated "doesn't validate the token" — and checks onlysubpresence and the nonce. This is a defensible reading of OIDC Core §3.1.3.7 cl. 8 (the token arrived over TLS straight from the token endpoint), but it is a real difference fromgo-oidc, which does full JWKS +iss/aud/exp+at_hashvalidation. Worth a second opinion on whether this repo wants to ship an example with that property.Risk & rollback
bun.locknow falls under the repo-wide Trivy scan, which runs withexit-code: 1on HIGH/CRITICAL. A HIGH advisory in the JS dev tree (vite/vitest/esbuild churn frequently) will turn every PR in this repo red — including Go-only ones. Dependabot has nobunecosystem andcodeql.ymlislanguage: ["go"], so this SPA gets neither auto-patching nor SAST.plan.mdparked CI as a human decision, so this PR does not touch it — but it should be a conscious call, not a surprise on the first red PR.connect-srcdepends on the issuer URL and a static meta-CSP would break Vite HMR. Documented in the README as a production hardening step instead.git rm -r vue-spa+ revert the twoREADME.mdedits. No existing example is affected.Reviewer guide
vue-spa/src/auth/userManager.ts— every setting is load-bearing; check the two stores and the revoke ordervue-spa/src/auth/useAuth.ts— nonce, event-driven user ref, refresh-token guard, single-flight renewalvue-spa/src/api/client.ts— 401 retry pathvue-spa/README.md— the "What is not verified" section makes a security claim; check it's rightsrc/views/*.vue,src/router/index.ts,src/App.vue,tsconfig*.json,index.html, rootREADME.mdbun.lock(325 lines, generated)plan.md.🤖 Generated with Claude Code