Skip to content

feat: add vue-spa browser-only SPA login example - #8

Merged
appleboy merged 6 commits into
mainfrom
feat/vue-spa-example
Jul 14, 2026
Merged

feat: add vue-spa browser-only SPA login example#8
appleboy merged 6 commits into
mainfrom
feat/vue-spa-example

Conversation

@appleboy

Copy link
Copy Markdown
Contributor

Summary

Adds vue-spa/, a Vue 3 + Vite + TypeScript example that runs Authorization Code + PKCE entirely in the browser via oidc-client-ts — a public client with no backend and no client secret. It is the counterpart to go-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:#3c763d
Loading

Green = new in this PR. go-webservice and 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

  • No AI was used in this PR
  • AI was used. Details:
    • Tool / model: Claude Code (Claude Fable 5)
    • AI-authored files: all 21 — every file under vue-spa/ and the root README.md edits. No line was hand-written.
    • Human line-by-line reviewed: none yet. The author exercised the app at runtime (and found the blockers below), but has not read the auth module line-by-line.
    • Automated review: a max-effort self-review (10 finder angles + adversarial verification + a gap sweep) produced 17 findings; 15 were fixed in this commit, 2 deliberately deferred (see Risk).

Important

This is AI-authored auth code with no human line-by-line review. src/auth/ and src/api/client.ts need a real read, not a spot-check.

Change classification

  • Leaf node (local impact)
  • Core code — needs line-by-line review

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:

  • Goal: give front-end engineers a browser-only (public client) Signet SSO example, since the existing go-oidc web example is a server-side flow and can't demonstrate "no backend, tokens in the browser".
  • May modify: new vue-spa/ directory + root README.md (Quick Reference row + a section).
  • Must not modify: any existing example — specifically go-webservice/main.go must not gain CORS; cross-origin is solved with a Vite proxy. Also Signet server itself, and the goreleaser config.
  • Constraints: Bun only (no npm/pnpm/yarn); 3 runtime deps (vue, vue-router, oidc-client-ts); no UI framework; no client secret anywhere; tokens in sessionStorage; monitorSession: false; no signoutRedirect().

must not modify boundary respected — verified: 0 files outside vue-spa/ and README.md are touched.

Verification

  • Unit tests — 14, all passing (bun run test)
  • Integration tests — none
  • At least 3 e2e tests — none. The plan explicitly ruled out Playwright: it needs a live Signet and real credentials, which an examples repo can't sustain in CI. Coverage was to come from the manual script below instead.
  • Stress / soak test — N/A
  • bun run typecheck (vue-tsc) and bun run build pass. build now 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:

  1. Sign-in fails at discovery: Failed to fetch on https://signet.local:8080/.well-known/openid-configuration. Failed to fetch is 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 with CORS_ENABLED=true, or whether the browser is rejecting a self-signed cert for signet.local.
  2. Call API cannot work at all: Signet itself is listening on *:8080 over HTTPS, but go-webservice/main.go:75 hard-codes :8080 plain HTTP — so go-webservice cannot bind, and the Vite proxy's plaintext request lands on Signet's TLS listener, producing 400 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 give go-webservice a PORT env 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:

cd vue-spa && cp .env.example .env   # set VITE_SIGNET_URL + VITE_CLIENT_ID
bun install && bun run dev           # http://localhost:5173

Signet side: CORS_ENABLED=true, CORS_ALLOWED_ORIGINS=http://localhost:5173, a public client (no secret) with redirect URI http://localhost:5173/callback.

Verifiability check

  • Inputs and outputs are documented (.env.example, README env table + routes table)
  • Reviewer can judge correctness from interface + tests alone — no. Core + unverified flow ⇒ line-by-line required.
  • Failures will surface in monitoring — N/A (example app, no monitoring)

Security check

  • No secrets in code — verified; the built bundle contains no configured secret, and .env is gitignored. Public client by construction.
  • All external inputs validated — state + nonce verified by oidc-client-ts; see caveat below.
  • Permission checks tested — scope enforcement is go-webservice's job (403 path is demonstrated, not unit-tested)
  • Rate limits applied — N/A (client-side)
  • Errors don't leak internals — no tokens in logs or error strings

Security-relevant fixes already made in this commit (each was a real defect found in review, verified against oidc-client-ts source):

Fix Why it mattered
Mint an explicit nonce oidc-client-ts only sends and verifies a nonce if the caller supplies one. signinRedirect() with no args — the tutorial default — silently gives you zero replay protection.
Set stateStore to sessionStorage It defaults to localStorage independently of userStore, so the in-flight PKCE code_verifier was outliving the tab and shared across tabs.
Revoke refresh_token before access_token The library revokes sequentially and stops at the first failure. The old order could leave a live refresh token at Signet with the only local copy already deleted.
Guard renewal on a refresh token being present Without it, signinSilent() silently falls back to a hidden iframe at silent_redirect_uri (which defaults to redirect_uri = /callback), hanging 10s before IFrame timed out.
Single-flight renewal Signet rotates refresh tokens; two concurrent 401 retries would replay an already-consumed token.
Derive user from UserManager events A renewal started inside the API client left the UI rendering a stale, expired session.

Note

The ID token signature is NOT verified, and the README now says so explicitly. oidc-client-ts never fetches the JWKS — its JWT helper is annotated "doesn't validate the token" — and checks only sub presence 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 from go-oidc, which does full JWKS + iss/aud/exp + at_hash validation. Worth a second opinion on whether this repo wants to ship an example with that property.

Risk & rollback

  • Risk — CI (deliberately not addressed): the committed bun.lock now falls under the repo-wide Trivy scan, which runs with exit-code: 1 on 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 no bun ecosystem and codeql.yml is language: ["go"], so this SPA gets neither auto-patching nor SAST. plan.md parked 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.
  • Risk — no CSP: not shipped, because the required connect-src depends on the issuer URL and a static meta-CSP would break Vite HMR. Documented in the README as a production hardening step instead.
  • Risk — unverified flow: see Verification.
  • Rollback: fully contained. git rm -r vue-spa + revert the two README.md edits. No existing example is affected.

Reviewer guide

  • Read carefully (AI-authored, unreviewed, security-sensitive):
    • vue-spa/src/auth/userManager.ts — every setting is load-bearing; check the two stores and the revoke order
    • vue-spa/src/auth/useAuth.ts — nonce, event-driven user ref, refresh-token guard, single-flight renewal
    • vue-spa/src/api/client.ts — 401 retry path
    • vue-spa/README.md — the "What is not verified" section makes a security claim; check it's right
  • Spot-check OK: src/views/*.vue, src/router/index.ts, src/App.vue, tsconfig*.json, index.html, root README.md
  • Skip: bun.lock (325 lines, generated)
  • Size note: 1,554 insertions total, but only 921 lines of hand-written code/config (rest is the lockfile + two READMEs). Over the 500-line guideline; it's a single self-contained new example, scoped in advance by plan.md.
  • Suggested reviewers: 2+ (core), ideally including someone who knows Signet's OAuth surface.

🤖 Generated with Claude Code

- 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
@appleboy
appleboy marked this pull request as ready for review July 13, 2026 14:46
Copilot AI review requested due to automatic review settings July 13, 2026 14:46

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Comment thread vue-spa/src/views/ProfileView.vue
Comment thread vue-spa/vite.config.ts
Comment thread vue-spa/src/views/LoginView.vue Outdated
- 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

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 20 out of 21 changed files in this pull request and generated 3 comments.

Comment thread vue-spa/src/views/ProfileView.vue
Comment thread vue-spa/src/views/LoginView.vue Outdated
Comment thread vue-spa/src/auth/useAuth.ts
- 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

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 20 out of 21 changed files in this pull request and generated 3 comments.

Comment thread vue-spa/README.md Outdated
Comment thread vue-spa/README.md Outdated
Comment thread vue-spa/README.md
@appleboy
appleboy requested a review from Copilot July 13, 2026 15:07
@appleboy
appleboy marked this pull request as draft July 13, 2026 15:07
…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

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 20 out of 21 changed files in this pull request and generated 3 comments.

Comment thread vue-spa/README.md
Comment thread README.md
Comment thread vue-spa/README.md

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 20 out of 21 changed files in this pull request and generated 4 comments.

Comment thread vue-spa/src/auth/userManager.ts Outdated
Comment thread vue-spa/src/auth/userManager.ts Outdated
Comment thread vue-spa/.env.example Outdated
Comment thread vue-spa/README.md
…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

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 20 out of 21 changed files in this pull request and generated 1 comment.

Comment thread vue-spa/src/api/client.ts Outdated
- 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

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 20 out of 21 changed files in this pull request and generated no new comments.

@appleboy
appleboy marked this pull request as ready for review July 14, 2026 12:55
@appleboy
appleboy merged commit 9fca038 into main Jul 14, 2026
5 checks passed
@appleboy
appleboy deleted the feat/vue-spa-example branch July 14, 2026 12:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants