From c618a4edd742f258f4484309d27d01ce2ab2f21a Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Sat, 7 Nov 2026 14:42:45 -0500 Subject: [PATCH 1/4] feat: show permission guards in the starter templates Each API starter and the Next.js starter serves GET /reports behind its adapter's permission guard for reports:read, beside the betaUser role route. The web and mobile starters show the reports only to a holder of reports:read, with the server guard as what enforces. READMEs and AGENTS.md carry the steps to create the permission and grant it to a role, and say that it needs auth API v0.23: on v0.22 the guard answers 403 to everyone. Moves the starters to the adapter and SDK releases that carry the guard and the client permission helpers. Closes #149 --- .changeset/starter-permission-guards.md | 9 ++ AGENTS.md | 5 +- shared/react-app/README.md | 4 +- .../components/ReportsPanel.test.tsx | 51 +++++++++++ shared/react-app/components/ReportsPanel.tsx | 82 +++++++++++++++++ shared/react-app/sync.json | 8 ++ templates/api/axum/AGENTS.md | 15 ++++ templates/api/axum/Cargo.lock | 4 +- templates/api/axum/Cargo.toml | 2 +- templates/api/axum/README.md | 47 ++++++++-- templates/api/axum/src/server.rs | 31 ++++++- templates/api/express/AGENTS.md | 13 +++ templates/api/express/README.md | 35 ++++++++ templates/api/express/package-lock.json | 30 +++---- templates/api/express/package.json | 2 +- .../src/controllers/reports.controller.ts | 12 +++ templates/api/express/src/index.ts | 7 ++ templates/api/express/src/routes/reports.ts | 8 ++ templates/api/fastapi/AGENTS.md | 14 +++ templates/api/fastapi/README.md | 48 ++++++++-- templates/api/fastapi/app/main.py | 17 ++++ templates/api/fastapi/pyproject.toml | 2 +- templates/api/fastapi/tests/test_app.py | 6 +- templates/api/fastapi/uv.lock | 8 +- templates/api/fastify/AGENTS.md | 14 ++- templates/api/fastify/README.md | 35 ++++++++ templates/api/fastify/package-lock.json | 30 +++---- templates/api/fastify/package.json | 2 +- .../src/controllers/reports.controller.ts | 12 +++ templates/api/fastify/src/index.ts | 2 + templates/api/fastify/src/routes/reports.ts | 14 +++ templates/api/gin/AGENTS.md | 14 +++ templates/api/gin/README.md | 47 ++++++++-- templates/api/gin/go.mod | 2 +- templates/api/gin/go.sum | 4 +- templates/api/gin/internal/server/server.go | 31 ++++++- .../api/gin/internal/server/server_test.go | 12 +-- templates/fullstack/nextjs/AGENTS.md | 21 +++++ templates/fullstack/nextjs/README.md | 36 ++++++++ templates/fullstack/nextjs/package-lock.json | 58 ++++++------ templates/fullstack/nextjs/package.json | 4 +- .../nextjs/src/app/(app)/beta/page.tsx | 8 +- .../nextjs/src/app/api/reports/route.test.ts | 22 +++++ .../nextjs/src/app/api/reports/route.ts | 37 ++++++++ .../nextjs/src/components/ReportsAccess.tsx | 88 +++++++++++++++++++ templates/mobile/expo/AGENTS.md | 6 ++ templates/mobile/expo/README.md | 34 +++++++ templates/mobile/expo/app/(app)/index.tsx | 30 +++++-- templates/mobile/expo/package-lock.json | 26 +++--- templates/mobile/expo/package.json | 2 +- templates/web/angular/AGENTS.md | 6 ++ templates/web/angular/README.md | 34 +++++++ templates/web/angular/package-lock.json | 24 ++--- templates/web/angular/package.json | 2 +- templates/web/angular/src/app/pages/beta.ts | 51 ++++++++++- templates/web/react-oauth/AGENTS.md | 6 ++ templates/web/react-oauth/README.md | 34 +++++++ templates/web/react-oauth/package-lock.json | 26 +++--- templates/web/react-oauth/package.json | 2 +- .../src/components/ReportsPanel.test.tsx | 51 +++++++++++ .../src/components/ReportsPanel.tsx | 82 +++++++++++++++++ .../web/react-oauth/src/pages/Session.tsx | 10 +++ templates/web/react-vite/AGENTS.md | 6 ++ templates/web/react-vite/README.md | 34 +++++++ templates/web/react-vite/package-lock.json | 26 +++--- templates/web/react-vite/package.json | 2 +- .../src/components/ReportsPanel.test.tsx | 51 +++++++++++ .../src/components/ReportsPanel.tsx | 82 +++++++++++++++++ .../web/react-vite/src/pages/Session.tsx | 10 +++ templates/web/svelte/AGENTS.md | 6 ++ templates/web/svelte/README.md | 34 +++++++ templates/web/svelte/package-lock.json | 24 ++--- templates/web/svelte/package.json | 2 +- .../svelte/src/routes/(app)/beta/+page.svelte | 45 +++++++++- templates/web/vue/AGENTS.md | 6 ++ templates/web/vue/README.md | 34 +++++++ templates/web/vue/package-lock.json | 24 ++--- templates/web/vue/package.json | 2 +- templates/web/vue/src/views/BetaView.vue | 46 +++++++++- 79 files changed, 1613 insertions(+), 200 deletions(-) create mode 100644 .changeset/starter-permission-guards.md create mode 100644 shared/react-app/components/ReportsPanel.test.tsx create mode 100644 shared/react-app/components/ReportsPanel.tsx create mode 100644 templates/api/express/src/controllers/reports.controller.ts create mode 100644 templates/api/express/src/routes/reports.ts create mode 100644 templates/api/fastify/src/controllers/reports.controller.ts create mode 100644 templates/api/fastify/src/routes/reports.ts create mode 100644 templates/fullstack/nextjs/src/app/api/reports/route.test.ts create mode 100644 templates/fullstack/nextjs/src/app/api/reports/route.ts create mode 100644 templates/fullstack/nextjs/src/components/ReportsAccess.tsx create mode 100644 templates/web/react-oauth/src/components/ReportsPanel.test.tsx create mode 100644 templates/web/react-oauth/src/components/ReportsPanel.tsx create mode 100644 templates/web/react-vite/src/components/ReportsPanel.test.tsx create mode 100644 templates/web/react-vite/src/components/ReportsPanel.tsx diff --git a/.changeset/starter-permission-guards.md b/.changeset/starter-permission-guards.md new file mode 100644 index 0000000..879713a --- /dev/null +++ b/.changeset/starter-permission-guards.md @@ -0,0 +1,9 @@ +--- +"seamless-templates": minor +--- + +Show permission guards in the starters. Every API starter, and the Next.js starter, serves `GET /reports` (`/api/reports` in Next.js) behind its adapter's permission guard for `reports:read`, beside the existing `betaUser` role route. The React starters show the reports on the Session page with `usePermissions().hasPermission`, and the Angular, Vue, SvelteKit and Expo starters gate their reports UI with `hasPermission`; the server guard is what enforces. Each README and AGENTS.md has the steps to create the permission and grant it to a role. + +Permissions need Seamless Auth API v0.23 or later. On v0.22 access tokens carry no permissions, so the guarded route answers 403 to everyone and the UI stays hidden. + +The starters move to the adapters and SDKs that carry the guard: `@seamless-auth/express` 0.25, `@seamless-auth/fastify` 0.16, `@seamless-auth/nextjs` 0.9, `@seamless-auth/react` 0.20, `@seamless-auth/angular` 0.5, `@seamless-auth/vue` 0.4, `@seamless-auth/svelte` 0.4, `@seamless-auth/react-native` 0.5, and `seamless-auth` 0.6 for Go, Rust and Python. With the Python 0.6 adapter, FastAPI guard refusals answer `{"error": ""}` instead of `{"detail": ""}`. diff --git a/AGENTS.md b/AGENTS.md index e2ea57d..0be0761 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,8 +71,9 @@ guidance may extend them but must not contradict them. cookie or password code, and the real commands). Every path, export, env var and command it names must exist in that template, so update it with any change that moves one. - `shared/react-app/`: the source of truth for what both React starters share (the design tokens in - `index.css`, the app shell layout, the UI kit under `components/kit`, and the fetch seam under - `lib/` that the kit calls). The CLI copies exactly + `index.css`, the app shell layout, the UI kit under `components/kit`, the fetch seam under + `lib/` that the kit calls, and `components/ReportsPanel.tsx`, the permission-gated panel on the + Session page). The CLI copies exactly one template directory into a new project, so a template cannot reference anything outside itself. Each template therefore carries a committed copy, written by `npm run sync:shared`. Edit the file under `shared/react-app/`, never a template's copy. The Next.js, Angular, Vue and SvelteKit diff --git a/shared/react-app/README.md b/shared/react-app/README.md index 07f003f..8da9ce7 100644 --- a/shared/react-app/README.md +++ b/shared/react-app/README.md @@ -1,8 +1,8 @@ # Shared React sources The source of truth for the parts of the React starters that must stay identical: -the design tokens, the app shell layout, the UI kit, and the fetch seam the kit -calls. +the design tokens, the app shell layout, the UI kit, the fetch seam the kit +calls, and the permission-gated reports panel on the Session page. Both `templates/web/react-vite` and `templates/web/react-oauth` carry a committed copy of everything here. The Next.js, Angular, Vue and SvelteKit starters carry diff --git a/shared/react-app/components/ReportsPanel.test.tsx b/shared/react-app/components/ReportsPanel.test.tsx new file mode 100644 index 0000000..bdc9e54 --- /dev/null +++ b/shared/react-app/components/ReportsPanel.test.tsx @@ -0,0 +1,51 @@ +import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const usePermissions = vi.fn(); +const apiFetch = vi.fn(); + +vi.mock("@seamless-auth/react", () => ({ + usePermissions: () => usePermissions(), +})); + +vi.mock("../lib/api", async (importOriginal) => ({ + ...(await importOriginal()), + apiFetch: (...args: unknown[]) => apiFetch(...args), +})); + +const { default: ReportsPanel } = await import("./ReportsPanel"); + +function holding(...permissions: string[]) { + usePermissions.mockReturnValue({ + loading: false, + hasPermission: (required: string) => permissions.includes(required), + }); +} + +describe("ReportsPanel", () => { + beforeEach(() => { + usePermissions.mockReset(); + apiFetch.mockReset(); + }); + + it("asks the API for nothing without reports:read", () => { + holding("invoices:read"); + + render(); + + expect(screen.getByText(/so the reports stay hidden/)).toBeInTheDocument(); + expect(apiFetch).not.toHaveBeenCalled(); + }); + + it("lists the reports for a holder of reports:read", async () => { + holding("reports:read"); + apiFetch.mockResolvedValue({ + reports: [{ id: "weekly-signups", title: "Weekly sign-ups" }], + }); + + render(); + + expect(await screen.findByText("Weekly sign-ups")).toBeInTheDocument(); + expect(apiFetch).toHaveBeenCalledWith("/reports"); + }); +}); diff --git a/shared/react-app/components/ReportsPanel.tsx b/shared/react-app/components/ReportsPanel.tsx new file mode 100644 index 0000000..bd28f35 --- /dev/null +++ b/shared/react-app/components/ReportsPanel.tsx @@ -0,0 +1,82 @@ +import { usePermissions } from "@seamless-auth/react"; +import { useEffect, useState } from "react"; + +import { ApiError, apiFetch } from "../lib/api"; + +type Report = { id: string; title: string }; + +/** + * The reports the API serves at GET /reports, for a session that holds + * `reports:read`. + * + * Hiding them is a courtesy, not access control: the API's permission guard on + * /reports is what enforces, and it refuses anyone without the permission + * whatever this renders. The permissions here are the ones the last session read + * returned, so a grant made since shows up after the next sign-in or refresh. + */ +export default function ReportsPanel() { + const { hasPermission, loading } = usePermissions(); + const canReadReports = hasPermission("reports:read"); + + const [reports, setReports] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + if (!canReadReports) return; + let active = true; + + apiFetch<{ reports: Report[] }>("/reports").then( + (body) => { + if (active) setReports(body.reports); + }, + (cause: unknown) => { + if (!active) return; + setError( + cause instanceof ApiError && cause.status === 403 + ? "The API refused: this session's token does not carry reports:read yet. Sign in again to pick up a new grant." + : "The reports request did not complete.", + ); + }, + ); + + return () => { + active = false; + }; + }, [canReadReports]); + + if (loading) return null; + + if (!canReadReports) { + return ( +

+ This user does not hold the{" "} + reports:read{" "} + permission, so the reports stay hidden. Permissions need Seamless Auth + API v0.23 or later and a role that grants them; the README has the + steps. +

+ ); + } + + return ( +
+

+ This user holds{" "} + reports:read, so + the reports from the API show here. +

+ + {error &&

{error}

} + + {reports && ( +
    + {reports.map((report) => ( +
  • + {report.title} +
  • + ))} +
+ )} +
+ ); +} diff --git a/shared/react-app/sync.json b/shared/react-app/sync.json index 254a15f..38283bf 100644 --- a/shared/react-app/sync.json +++ b/shared/react-app/sync.json @@ -31,6 +31,14 @@ "from": "layouts/Layout.tsx", "to": "src/layouts/Layout.tsx" }, + { + "from": "components/ReportsPanel.tsx", + "to": "src/components/ReportsPanel.tsx" + }, + { + "from": "components/ReportsPanel.test.tsx", + "to": "src/components/ReportsPanel.test.tsx" + }, { "from": "components/kit", "to": "src/components/kit" diff --git a/templates/api/axum/AGENTS.md b/templates/api/axum/AGENTS.md index 6a6e70e..26654c6 100644 --- a/templates/api/axum/AGENTS.md +++ b/templates/api/axum/AGENTS.md @@ -29,6 +29,7 @@ it, not to rebuild it. - `require_auth()` puts a `seamless_auth::User` in the request extensions. `require_user` finds or creates the local `db::User` (`src/db.rs`) and inserts it too. - `require_role("betaUser", ...)` gates the example route. + - `auth.require_permission(["reports:read"])` gates `/reports`. - `src/config.rs`: reads and checks the environment at boot. ## Rules @@ -59,6 +60,20 @@ Omit the inner `route_layer` for a route any signed-in user may call. Public rou In a handler, take `Extension(user): Extension` for the local record, or `Extension` for the session (`id`, `email`, `phone`, `roles`). +Permissions work the same way, with the adapter's permission guard: + +```rust +.route("/reports", get(reports_content).route_layer( + auth.require_permission(["reports:read"]).expect("a non-empty permission"), +)) +``` + +Permission names match exactly and need auth API v0.23 or later (unreleased); before that the +guard answers 403 to everyone. Granting `reports:read` takes the admin console (Roles page) or the +admin API (`POST /admin/permissions`, then `PUT /admin/roles/instance/betaUser/permissions`), with a +stepped-up `admin:write` session; the README has the steps. Do not reimplement the check by +reading claims yourself. + ## Configuration All configuration is environment variables; see `.env.example`. `APP_ENV`, `RUST_LOG`, `PORT`, diff --git a/templates/api/axum/Cargo.lock b/templates/api/axum/Cargo.lock index 54bb688..ba25553 100644 --- a/templates/api/axum/Cargo.lock +++ b/templates/api/axum/Cargo.lock @@ -1509,9 +1509,9 @@ dependencies = [ [[package]] name = "seamless-auth" -version = "0.4.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84d6503321edef570a7c2fae5697ef089901e5124336c3aa0c6656c7a2d906a3" +checksum = "5f763a6fab2c37686a4104b68d69cca596bce1672514da3a025081ff342a2f03" dependencies = [ "axum", "base64 0.22.1", diff --git a/templates/api/axum/Cargo.toml b/templates/api/axum/Cargo.toml index 2ce96a3..32600d9 100644 --- a/templates/api/axum/Cargo.toml +++ b/templates/api/axum/Cargo.toml @@ -8,7 +8,7 @@ license = "Apache-2.0" [dependencies] # The Seamless Auth adapter: /auth, the session guard, and the admin console. -seamless-auth = "0.4" +seamless-auth = "0.6" axum = "0.8" tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "signal"] } # Postgres, with migrations embedded in the binary and run on boot. diff --git a/templates/api/axum/README.md b/templates/api/axum/README.md index 0d06bdc..5e17104 100644 --- a/templates/api/axum/README.md +++ b/templates/api/axum/README.md @@ -22,6 +22,7 @@ starter, so the web and mobile starters work with it unchanged. - Automatic user resolution: every authenticated request finds or creates a local `users` row keyed by the Seamless Auth user id. A new row takes its email and phone from the auth server's profile. - Role-based access: `GET /beta_users` requires the `betaUser` role. +- Permission-based access: `GET /reports` requires the `reports:read` permission, through the adapter's guard. - The session guard accepts the session cookie, or the auth server's access token as a bearer credential (how the mobile starter signs its requests), and blocks cross-site state changes on a cookie session. @@ -105,12 +106,46 @@ started in the console verify. ## Routes -| Method | Path | Description | -| ------ | ------------- | ---------------------------------------------------- | -| GET | `/` | Health check | -| * | `/auth/*` | The Seamless Auth adapter | -| GET | `/console/*` | The admin dashboard, when `SERVE_ADMIN_CONSOLE=true` | -| GET | `/beta_users` | Example route, restricted to the `betaUser` role | +| Method | Path | Description | +| ------ | ------------- | ------------------------------------------------------------------------------------ | +| GET | `/` | Health check | +| * | `/auth/*` | The Seamless Auth adapter | +| GET | `/console/*` | The admin dashboard, when `SERVE_ADMIN_CONSOLE=true` | +| GET | `/beta_users` | Example route, restricted to the `betaUser` role | +| GET | `/reports` | Example route, restricted to the `reports:read` permission (auth API v0.23 or later) | + +## Permissions + +`GET /reports` is guarded by `require_permission(["reports:read"])` from the `seamless-auth` crate +(a route layer in [src/server.rs](src/server.rs)). The guard reads the permissions on the verified +access token, matches them exactly (`reports:write` does not grant `reports:read`), and answers `403 +{"error":"insufficient_permission"}` to a session without the permission. `reports:read` is the +example permission in every Seamless starter, so one grant opens the matching route and UI whichever +starters you combine. + +**This needs Seamless Auth API v0.23 or later, which is not released yet.** An older API issues +access tokens with no `permissions` claim, so on API v0.22 the guard denies every request and +`/reports` answers 403 to everyone. That is the guard failing closed, not a fault in this starter. + +To open `/reports` once your auth server runs v0.23: + +1. Sign in to the admin console as an admin with `admin:write` (the `OWNER_EMAIL` account + `seamless init` sets up has it), with a passkey or authenticator app enrolled: permission + changes need a fresh step-up. +2. Create the permission `reports:read`, and grant it to the `betaUser` instance role. +3. Give your user the `betaUser` role. +4. Sign in again. An access token carries the permissions it was issued with, so a grant reaches + a session at its next token. + +The console's Roles page manages permissions and the roles that grant them. It makes these calls on +the auth server, which you can also script from a stepped-up admin session: + +```text +POST /admin/permissions {"name": "reports:read"} +PUT /admin/roles/instance/betaUser/permissions {"permissions": ["reports:read"]} +``` + +The `PUT` replaces everything the role grants, so list any permissions it already has. ## Checks diff --git a/templates/api/axum/src/server.rs b/templates/api/axum/src/server.rs index 52ad402..a2e5843 100644 --- a/templates/api/axum/src/server.rs +++ b/templates/api/axum/src/server.rs @@ -74,6 +74,18 @@ pub fn router(cfg: &Config, auth: &Adapter, pool: PgPool) -> Router { require_role("betaUser", req, next) })), ) + // reports:read is the example permission in every Seamless starter, so one + // role mapped to it in the admin console opens this route in whichever + // starter you scaffolded. Permissions need auth API v0.23 or later: an + // older API issues no permissions claim, and this route answers 403 to + // everyone until then. + .route( + "/reports", + get(reports_content).route_layer( + auth.require_permission(["reports:read"]) + .expect("a non-empty permission"), + ), + ) .route_layer(middleware::from_fn_with_state(state.clone(), require_user)) .route_layer(auth.require_auth()) .with_state(state); @@ -108,6 +120,17 @@ async fn beta_content(Extension(user): Extension) -> Json) -> Json { + Json(json!({ + "message": "Reports for holders of the reports:read permission.", + "reports": [ + { "id": "weekly-signups", "title": "Weekly sign-ups" }, + { "id": "active-sessions", "title": "Active sessions" }, + ], + "user": user, + })) +} + /// Finds or creates the application's own record of the signed-in user, keyed by /// the Seamless Auth user id. A new record takes its email and phone from the /// auth API's profile: an access token carries neither, so a bearer session would @@ -383,9 +406,11 @@ mod tests { #[tokio::test] async fn app_routes_need_a_session() { - let (status, _, text) = send(app(false).await, "GET", "/beta_users", &[], "").await; - assert_eq!(status, 401); - assert!(text.contains("unauthenticated"), "{text}"); + for target in ["/beta_users", "/reports"] { + let (status, _, text) = send(app(false).await, "GET", target, &[], "").await; + assert_eq!(status, 401, "{target}"); + assert!(text.contains("unauthenticated"), "{target}: {text}"); + } } #[tokio::test] diff --git a/templates/api/express/AGENTS.md b/templates/api/express/AGENTS.md index 5ef8153..7c2e10b 100644 --- a/templates/api/express/AGENTS.md +++ b/templates/api/express/AGENTS.md @@ -24,6 +24,7 @@ already wired. Your job when extending this app is to keep using it, not to rebu - `requireAuth({...})`, which rejects requests without a valid session and sets `req.user`. - `requireUser(seamlessAuthOptions)`, which sets `req.appUser`. - `requireRole("betaUser")` on `/beta_users`. + - `requirePermission("reports:read")` on `/reports` (`src/routes/reports.ts`). - `src/middleware/requireUser.ts`: resolves the Seamless user with `getSeamlessUser(req, opts)` and finds or creates the local `User` row (`models/user.ts`), keyed by the Seamless user id. - `src/types/index.d.ts`: types `req.user` (`SeamlessAuthUser`) and `req.appUser`. @@ -53,6 +54,18 @@ routers there, after those two lines, the way the example does: app.use("/beta_users", requireRole("betaUser"), beta); ``` +Permissions work the same way, with the adapter's permission guard: + +```ts +app.use("/reports", requirePermission("reports:read"), reports); +``` + +Permission names match exactly and need auth API v0.23 or later (unreleased); before that the +guard answers 403 to everyone. Granting `reports:read` takes the admin console (Roles page) or the +admin API (`POST /admin/permissions`, then `PUT /admin/roles/instance/betaUser/permissions`), with a +stepped-up `admin:write` session; the README has the steps. Do not reimplement the check by +reading claims yourself. + Drop `requireRole(...)` for a route any signed-in user may call. Anything that must stay public goes above the `requireAuth` call. In a handler, the signed-in user is `req.user` (from Seamless: `id`, `email`, `phone`, `roles`) and the local record is `req.appUser`. diff --git a/templates/api/express/README.md b/templates/api/express/README.md index 1220fea..31eafb8 100644 --- a/templates/api/express/README.md +++ b/templates/api/express/README.md @@ -19,6 +19,7 @@ current user from the session, and protects an example route by role. - Automatic user resolution: `requireUser` finds or creates a local `User` keyed by the Seamless Auth user id, and exposes it as `req.appUser`. - Role-based access: `GET /beta_users` is gated by `requireRole("beta_user")`. +- Permission-based access: `GET /reports` is gated by `requirePermission("reports:read")`. - A boot-time environment check that refuses to start on missing configuration and names every problem at once. - Sequelize + Postgres with migrations that run automatically on boot. @@ -165,6 +166,40 @@ npm run db:create # create the database if it is missing | ALL | `/auth/*` | Seamless Auth server-mode adapter | | GET | `/console/*` | Seamless admin dashboard, reverse-proxied from the auth server (only when `SERVE_ADMIN_CONSOLE=true`) | | GET | `/beta_users` | Example route, restricted to the `beta_user` role | +| GET | `/reports` | Example route, restricted to the `reports:read` permission (auth API v0.23 or later) | + +## Permissions + +`GET /reports` is guarded by `requirePermission("reports:read")` from `@seamless-auth/express` +(mounted in [src/index.ts](src/index.ts)). The guard reads the permissions on the verified access +token, matches them exactly (`reports:write` does not grant `reports:read`), and answers `403 +{"error":"insufficient_permission"}` to a session without the permission. `reports:read` is the +example permission in every Seamless starter, so one grant opens the matching route and UI whichever +starters you combine. + +**This needs Seamless Auth API v0.23 or later, which is not released yet.** An older API issues +access tokens with no `permissions` claim, so on API v0.22 the guard denies every request and +`/reports` answers 403 to everyone. That is the guard failing closed, not a fault in this starter. + +To open `/reports` once your auth server runs v0.23: + +1. Sign in to the admin console as an admin with `admin:write` (the `OWNER_EMAIL` account + `seamless init` sets up has it), with a passkey or authenticator app enrolled: permission + changes need a fresh step-up. +2. Create the permission `reports:read`, and grant it to the `betaUser` instance role. +3. Give your user the `betaUser` role. +4. Sign in again. An access token carries the permissions it was issued with, so a grant reaches + a session at its next token. + +The console's Roles page manages permissions and the roles that grant them. It makes these calls on +the auth server, which you can also script from a stepped-up admin session: + +```text +POST /admin/permissions {"name": "reports:read"} +PUT /admin/roles/instance/betaUser/permissions {"permissions": ["reports:read"]} +``` + +The `PUT` replaces everything the role grants, so list any permissions it already has. ## Admin console diff --git a/templates/api/express/package-lock.json b/templates/api/express/package-lock.json index 5dd29c6..27a6252 100644 --- a/templates/api/express/package-lock.json +++ b/templates/api/express/package-lock.json @@ -9,7 +9,7 @@ "version": "0.2.0", "license": "Apache-2.0", "dependencies": { - "@seamless-auth/express": "^0.23.0", + "@seamless-auth/express": "^0.25.0", "cookie-parser": "^1.4.7", "cors": "^2.8.5", "dotenv": "^16.6.1", @@ -1328,12 +1328,12 @@ ] }, "node_modules/@seamless-auth/core": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@seamless-auth/core/-/core-0.23.0.tgz", - "integrity": "sha512-KqVgNRyTNq2vO7ZJGCfskEAo2LW7UjFuE06GOUTc0nHMvfQYxtnmNaAb7L0goZnEaiwN2nkIrid4hlfGmmJHgg==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@seamless-auth/core/-/core-0.25.0.tgz", + "integrity": "sha512-qOYJUNYPKs0UXPg/xbyWG74q16fe7MmXMvNqeV2IRnjl7S1Lsao4rwC06gwz2UZtpuBd3/kbWotCJEo17EXoJw==", "license": "Apache-2.0", "dependencies": { - "@seamless-auth/types": "^0.34.0", + "@seamless-auth/types": "^0.38.0", "@types/jsonwebtoken": "^9.0.10", "jose": "^6.1.3", "jsonwebtoken": "^9.0.2" @@ -1343,12 +1343,12 @@ } }, "node_modules/@seamless-auth/express": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@seamless-auth/express/-/express-0.23.0.tgz", - "integrity": "sha512-ouhXXnASVhqYvjs0qAckUr7W8MP3cFpi+e8W6cmndi6o6NhWHCwET4RglssoYANriWc/LgEzD+S0rNHopqOgUg==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@seamless-auth/express/-/express-0.25.0.tgz", + "integrity": "sha512-5INUJOAF9CBJN7lN0Ah+IaRqq1MwGFwHjuXKSvSh9zahWXqkP3/7f9fQr4eWv61fFe3ptTxq2Gr/J3KpviZPCQ==", "license": "Apache-2.0", "dependencies": { - "@seamless-auth/core": "^0.23.0", + "@seamless-auth/core": "^0.25.0", "cookie-parser": "^1.4.6", "jsonwebtoken": "^9.0.3" }, @@ -1361,9 +1361,9 @@ } }, "node_modules/@seamless-auth/types": { - "version": "0.34.0", - "resolved": "https://registry.npmjs.org/@seamless-auth/types/-/types-0.34.0.tgz", - "integrity": "sha512-bTgZkEdpXwAejxwnA1J8vTGAq+FW9L6J4n97q2MI6SqWWIOqXyaF6zMTkzeKqNTvfgF0NIn2DHh9qXL+92FN/A==", + "version": "0.38.0", + "resolved": "https://registry.npmjs.org/@seamless-auth/types/-/types-0.38.0.tgz", + "integrity": "sha512-qALF5GFQEv+9yucQdn179cpTdEMMH/PjtZWAHatWk1qUEOzb1ElOqoBZgC4/THAk6u4aLgDyuDt/dCD/BzloqA==", "license": "Apache-2.0", "dependencies": { "zod": "^4.5.0" @@ -3936,9 +3936,9 @@ } }, "node_modules/jose": { - "version": "6.2.14", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.14.tgz", - "integrity": "sha512-3JasLuki1NRLQvY4wCoC37KZk4qxoBx2k2aFqLxT84IezqW0bkJKnNtxnZj/Fu1xsQivoc/lytxaRHJoGLjJ1A==", + "version": "6.2.15", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.15.tgz", + "integrity": "sha512-qdF6ewcZARtef63fJ9AKKZPgxw+t7USH6HLXYcD4CrdmSvDp79rpJSPFEKc90QBeGS63u1KQtN2MZQHC0yDIzA==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" diff --git a/templates/api/express/package.json b/templates/api/express/package.json index 0ed5d94..4c2906b 100644 --- a/templates/api/express/package.json +++ b/templates/api/express/package.json @@ -25,7 +25,7 @@ "check": "npm run typecheck && npm run lint && npm run format:check && npm run test" }, "dependencies": { - "@seamless-auth/express": "^0.23.0", + "@seamless-auth/express": "^0.25.0", "cookie-parser": "^1.4.7", "cors": "^2.8.5", "dotenv": "^16.6.1", diff --git a/templates/api/express/src/controllers/reports.controller.ts b/templates/api/express/src/controllers/reports.controller.ts new file mode 100644 index 0000000..44fa5e2 --- /dev/null +++ b/templates/api/express/src/controllers/reports.controller.ts @@ -0,0 +1,12 @@ +import { Request, Response } from "express"; + +export function getReports(req: Request, res: Response) { + return res.json({ + message: "Reports for holders of the reports:read permission.", + reports: [ + { id: "weekly-signups", title: "Weekly sign-ups" }, + { id: "active-sessions", title: "Active sessions" }, + ], + user: req.user, + }); +} diff --git a/templates/api/express/src/index.ts b/templates/api/express/src/index.ts index 99647a3..c44d8b3 100644 --- a/templates/api/express/src/index.ts +++ b/templates/api/express/src/index.ts @@ -5,6 +5,7 @@ import cookieParser from "cookie-parser"; import createSeamlessAuthServer, { createSeamlessConsoleProxy, requireAuth, + requirePermission, requireRole, SeamlessAuthServerOptions, SeamlessAuthMessagingOptions, @@ -13,6 +14,7 @@ import { connectToDb } from "./db.js"; import { initializeModels } from "../models/index.js"; import beta from "./routes/beta.js"; +import reports from "./routes/reports.js"; import { requireUser } from "./middleware/requireUser.js"; import { assertEnvironment, @@ -158,6 +160,11 @@ app.use( app.use(requireUser(seamlessAuthOptions)); app.use("/beta_users", requireRole("betaUser"), beta); +// reports:read is the example permission in every Seamless starter, so one role +// mapped to it in the admin console opens this route in whichever starter you +// scaffolded. Permissions need auth API v0.23 or later: an older API issues no +// permissions claim, and this route answers 403 to everyone until then. +app.use("/reports", requirePermission("reports:read"), reports); const models = await initializeModels(); diff --git a/templates/api/express/src/routes/reports.ts b/templates/api/express/src/routes/reports.ts new file mode 100644 index 0000000..8c4a0de --- /dev/null +++ b/templates/api/express/src/routes/reports.ts @@ -0,0 +1,8 @@ +import { Router } from "express"; +import { getReports } from "../controllers/reports.controller.js"; + +const router = Router(); + +router.get("/", getReports); + +export default router; diff --git a/templates/api/fastapi/AGENTS.md b/templates/api/fastapi/AGENTS.md index fc706c6..fd7ac2e 100644 --- a/templates/api/fastapi/AGENTS.md +++ b/templates/api/fastapi/AGENTS.md @@ -55,6 +55,20 @@ Drop the `require_role` dependency for a route any signed-in user may call. A ro on neither `session_user` nor `app_user` is public. Take `session: User = Depends(session_user)` for the session (`id`, `email`, `phone`, `roles`) or `Depends(app_user)` for the local record. +Permissions work the same way, with the adapter's permission guard: + +```python +reports_reader = RequireUser(auth, permissions=["reports:read"]) + +@app.get("/reports", dependencies=[Depends(reports_reader)]) +``` + +Permission names match exactly and need auth API v0.23 or later (unreleased); before that the +guard answers 403 to everyone. Granting `reports:read` takes the admin console (Roles page) or the +admin API (`POST /admin/permissions`, then `PUT /admin/roles/instance/betaUser/permissions`), with a +stepped-up `admin:write` session; the README has the steps. Do not reimplement the check by +reading claims yourself. + ## Configuration All configuration is environment variables; see `.env.example`. `APP_ENV`, `LOG_LEVEL`, `PORT`, diff --git a/templates/api/fastapi/README.md b/templates/api/fastapi/README.md index 92667ca..1877881 100644 --- a/templates/api/fastapi/README.md +++ b/templates/api/fastapi/README.md @@ -22,6 +22,7 @@ starter, so the web and mobile starters work with it unchanged. - Automatic user resolution: every authenticated request finds or creates a local `users` row keyed by the Seamless Auth user id. A new row takes its email and phone from the auth server's profile. - Role-based access: `GET /beta_users` requires the `betaUser` role. +- Permission-based access: `GET /reports` requires the `reports:read` permission, through the adapter's guard. - The session guard accepts the session cookie, or the auth server's access token as a bearer credential (how the mobile starter signs its requests), and blocks cross-site state changes on a cookie session. @@ -104,12 +105,47 @@ started in the console verify. ## Routes -| Method | Path | Description | -| ------ | ------------- | ---------------------------------------------------- | -| GET | `/` | Health check | -| * | `/auth/*` | The Seamless Auth adapter | -| GET | `/console/*` | The admin dashboard, when `SERVE_ADMIN_CONSOLE=true` | -| GET | `/beta_users` | Example route, restricted to the `betaUser` role | +| Method | Path | Description | +| ------ | ------------- | ------------------------------------------------------------------------------------ | +| GET | `/` | Health check | +| * | `/auth/*` | The Seamless Auth adapter | +| GET | `/console/*` | The admin dashboard, when `SERVE_ADMIN_CONSOLE=true` | +| GET | `/beta_users` | Example route, restricted to the `betaUser` role | +| GET | `/reports` | Example route, restricted to the `reports:read` permission (auth API v0.23 or later) | + +## Permissions + +`GET /reports` is guarded by `RequireUser(auth, permissions=["reports:read"])` from `seamless-auth` +(a route dependency in [app/main.py](app/main.py)). The guard reads the permissions on the verified +access token, matches them exactly (`reports:write` does not grant `reports:read`), and answers `403 +{"error":"insufficient_permission"}` to a session without the permission. `reports:read` is the +example permission in every Seamless starter, so one grant opens the matching route and UI whichever +starters you combine. Adapter refusals, this one included, answer `{"error": ""}` +(seamless-auth 0.6.0 and later), not FastAPI's `{"detail": ...}`. + +**This needs Seamless Auth API v0.23 or later, which is not released yet.** An older API issues +access tokens with no `permissions` claim, so on API v0.22 the guard denies every request and +`/reports` answers 403 to everyone. That is the guard failing closed, not a fault in this starter. + +To open `/reports` once your auth server runs v0.23: + +1. Sign in to the admin console as an admin with `admin:write` (the `OWNER_EMAIL` account + `seamless init` sets up has it), with a passkey or authenticator app enrolled: permission + changes need a fresh step-up. +2. Create the permission `reports:read`, and grant it to the `betaUser` instance role. +3. Give your user the `betaUser` role. +4. Sign in again. An access token carries the permissions it was issued with, so a grant reaches + a session at its next token. + +The console's Roles page manages permissions and the roles that grant them. It makes these calls on +the auth server, which you can also script from a stepped-up admin session: + +```text +POST /admin/permissions {"name": "reports:read"} +PUT /admin/roles/instance/betaUser/permissions {"permissions": ["reports:read"]} +``` + +The `PUT` replaces everything the role grants, so list any permissions it already has. ## Checks diff --git a/templates/api/fastapi/app/main.py b/templates/api/fastapi/app/main.py index 07c4b57..d269605 100644 --- a/templates/api/fastapi/app/main.py +++ b/templates/api/fastapi/app/main.py @@ -156,6 +156,23 @@ def beta_users(user: db.User = Depends(app_user)) -> dict[str, Any]: # noqa: B0 "user": user.as_json(), } + # reports:read is the example permission in every Seamless starter, so one role + # mapped to it in the admin console opens this route in whichever starter you + # scaffolded. Permissions need auth API v0.23 or later: an older API issues no + # permissions claim, and this route answers 403 to everyone until then. + reports_reader = RequireUser(auth, permissions=["reports:read"]) + + @app.get("/reports", dependencies=[Depends(reports_reader)]) + def reports(user: db.User = Depends(app_user)) -> dict[str, Any]: # noqa: B008 + return { + "message": "Reports for holders of the reports:read permission.", + "reports": [ + {"id": "weekly-signups", "title": "Weekly sign-ups"}, + {"id": "active-sessions", "title": "Active sessions"}, + ], + "user": user.as_json(), + } + return app diff --git a/templates/api/fastapi/pyproject.toml b/templates/api/fastapi/pyproject.toml index 27a8a28..4ee2b9b 100644 --- a/templates/api/fastapi/pyproject.toml +++ b/templates/api/fastapi/pyproject.toml @@ -6,7 +6,7 @@ requires-python = ">=3.11" license = "Apache-2.0" dependencies = [ # The Seamless Auth adapter: /auth, the session guard, and the admin console. - "seamless-auth[fastapi]>=0.4,<0.5", + "seamless-auth[fastapi]>=0.6,<0.7", "uvicorn[standard]>=0.32", # Postgres, with a small pool for the request threads. "psycopg[binary]>=3.2", diff --git a/templates/api/fastapi/tests/test_app.py b/templates/api/fastapi/tests/test_app.py index 8d6c060..0464826 100644 --- a/templates/api/fastapi/tests/test_app.py +++ b/templates/api/fastapi/tests/test_app.py @@ -53,8 +53,10 @@ def test_the_console_is_served_when_enabled() -> None: def test_app_routes_need_a_session() -> None: - r = client().get("/beta_users") - assert r.status_code == 401 + for target in ("/beta_users", "/reports"): + r = client().get(target) + assert r.status_code == 401, target + assert r.json() == {"error": "unauthenticated"}, target def test_cors() -> None: diff --git a/templates/api/fastapi/uv.lock b/templates/api/fastapi/uv.lock index 7fe34c0..6a0ee4a 100644 --- a/templates/api/fastapi/uv.lock +++ b/templates/api/fastapi/uv.lock @@ -1038,7 +1038,7 @@ requires-dist = [ { name = "psycopg", extras = ["binary"], specifier = ">=3.2" }, { name = "psycopg-pool", specifier = ">=3.2" }, { name = "python-dotenv", specifier = ">=1.0" }, - { name = "seamless-auth", extras = ["fastapi"], specifier = ">=0.4,<0.5" }, + { name = "seamless-auth", extras = ["fastapi"], specifier = ">=0.6,<0.7" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.32" }, ] @@ -1051,15 +1051,15 @@ dev = [ [[package]] name = "seamless-auth" -version = "0.4.0" +version = "0.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx2" }, { name = "pyjwt", extra = ["crypto"] }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ef/30/0cc2168f05f07e3077abd2fa49616e7f3b207e0b84a6b42fe9bffc82190d/seamless_auth-0.4.0.tar.gz", hash = "sha256:1448d6af049e7f1e84065532d2ce747c0ae403e30650132197e6eb0636ead944", size = 97243, upload-time = "2026-11-01T05:31:04.45Z" } +sdist = { url = "https://files.pythonhosted.org/packages/80/3d/b9e480f3fd3f1a4f7c9dc210ea847fb924757e9d598dd48f25bb6d137417/seamless_auth-0.6.0.tar.gz", hash = "sha256:f40e07cbc6b55d8533cba64771a8065b0ae074a7843159d5327e2786a42d5b5f", size = 133234, upload-time = "2026-11-07T04:08:49.748Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/18/12/dd3b7b9ce495f11b359d46fdd605cfe2a6b2951e12a425ce23ed57c5c265/seamless_auth-0.4.0-py3-none-any.whl", hash = "sha256:2a9d0c88a1470a13f6b767335a71f020905f85495058cd2b26f27e2a49b70f66", size = 58808, upload-time = "2026-11-01T05:31:03.024Z" }, + { url = "https://files.pythonhosted.org/packages/be/0c/b33706826024812acf2e610751a1f9b858df29fcda260088c1592e90fe68/seamless_auth-0.6.0-py3-none-any.whl", hash = "sha256:5cabafe363f0dc15d400e0cf593136884af7ae2fa9a6e5d3a0d8915e3ba8428e", size = 70197, upload-time = "2026-11-07T04:08:48.107Z" }, ] [package.optional-dependencies] diff --git a/templates/api/fastify/AGENTS.md b/templates/api/fastify/AGENTS.md index 9cde8c9..dd9f943 100644 --- a/templates/api/fastify/AGENTS.md +++ b/templates/api/fastify/AGENTS.md @@ -25,7 +25,7 @@ wired. Your job when extending this app is to keep using it, not to rebuild it. `requireUser(seamlessAuthOptions)` preHandler hooks, then the app's routes. - `src/hooks/requireUser.ts`: resolves the Seamless user with `getSeamlessUser(req, opts)` and finds or creates the local `User` row (`models/user.ts`), keyed by the Seamless user id. -- `src/routes/beta.ts`: the example route, gated with `requireRole("betaUser")`. +- `src/routes/reports.ts`: the permission example, gated with `requirePermission("reports:read")`. - `src/types/index.d.ts`: adds `request.appUser`. `request.user` is declared by `@seamless-auth/fastify` itself. - `src/lib/env.ts`: `assertEnvironment()` fails boot on missing auth or database settings. @@ -59,6 +59,18 @@ For a role, add it per route, as `src/routes/beta.ts` does: `secured`. In a handler, the signed-in user is `request.user` (from Seamless: `id`, `email`, `phone`, `roles`) and the local record is `request.appUser`. +Permissions work the same way, with the adapter's permission guard: + +```ts +app.get("/", { preHandler: requirePermission("reports:read") }, handler); +``` + +Permission names match exactly and need auth API v0.23 or later (unreleased); before that the +guard answers 403 to everyone. Granting `reports:read` takes the admin console (Roles page) or the +admin API (`POST /admin/permissions`, then `PUT /admin/roles/instance/betaUser/permissions`), with a +stepped-up `admin:write` session; the README has the steps. Do not reimplement the check by +reading claims yourself. + ## Configuration All configuration is environment variables; see `.env.example`. Auth: `AUTH_SERVER_URL`, diff --git a/templates/api/fastify/README.md b/templates/api/fastify/README.md index 5a97703..fee3537 100644 --- a/templates/api/fastify/README.md +++ b/templates/api/fastify/README.md @@ -19,6 +19,7 @@ current user from the session, and protects an example route by role. - Automatic user resolution: `requireUser` finds or creates a local `User` keyed by the Seamless Auth user id, and exposes it as `request.appUser`. - Role-based access: `GET /beta_users` is gated by `requireRole("betaUser")`. +- Permission-based access: `GET /reports` is gated by `requirePermission("reports:read")`. - A boot-time environment check that refuses to start on missing configuration and names every problem at once. - Sequelize + Postgres with migrations that run automatically on boot. @@ -191,6 +192,40 @@ npm run db:create # create the database if it is missing | ALL | `/auth/*` | Seamless Auth server-mode plugin | | GET | `/console/*` | Seamless admin dashboard, reverse-proxied from the auth server (only when `SERVE_ADMIN_CONSOLE=true`) | | GET | `/beta_users` | Example route, restricted to the `betaUser` role | +| GET | `/reports` | Example route, restricted to the `reports:read` permission (auth API v0.23 or later) | + +## Permissions + +`GET /reports` is guarded by `requirePermission("reports:read")` from `@seamless-auth/fastify` (a +`preHandler` in [src/routes/reports.ts](src/routes/reports.ts)). The guard reads the permissions on +the verified access token, matches them exactly (`reports:write` does not grant `reports:read`), and +answers `403 {"error":"insufficient_permission"}` to a session without the permission. +`reports:read` is the example permission in every Seamless starter, so one grant opens the matching +route and UI whichever starters you combine. + +**This needs Seamless Auth API v0.23 or later, which is not released yet.** An older API issues +access tokens with no `permissions` claim, so on API v0.22 the guard denies every request and +`/reports` answers 403 to everyone. That is the guard failing closed, not a fault in this starter. + +To open `/reports` once your auth server runs v0.23: + +1. Sign in to the admin console as an admin with `admin:write` (the `OWNER_EMAIL` account + `seamless init` sets up has it), with a passkey or authenticator app enrolled: permission + changes need a fresh step-up. +2. Create the permission `reports:read`, and grant it to the `betaUser` instance role. +3. Give your user the `betaUser` role. +4. Sign in again. An access token carries the permissions it was issued with, so a grant reaches + a session at its next token. + +The console's Roles page manages permissions and the roles that grant them. It makes these calls on +the auth server, which you can also script from a stepped-up admin session: + +```text +POST /admin/permissions {"name": "reports:read"} +PUT /admin/roles/instance/betaUser/permissions {"permissions": ["reports:read"]} +``` + +The `PUT` replaces everything the role grants, so list any permissions it already has. ## Admin console diff --git a/templates/api/fastify/package-lock.json b/templates/api/fastify/package-lock.json index fb70c98..96f1bfc 100644 --- a/templates/api/fastify/package-lock.json +++ b/templates/api/fastify/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "@fastify/cookie": "^11.1.2", "@fastify/cors": "^11.3.0", - "@seamless-auth/fastify": "^0.14.0", + "@seamless-auth/fastify": "^0.16.0", "dotenv": "^16.6.1", "fastify": "^5.11.0", "pg": "^8.16.3", @@ -1484,12 +1484,12 @@ ] }, "node_modules/@seamless-auth/core": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@seamless-auth/core/-/core-0.23.0.tgz", - "integrity": "sha512-KqVgNRyTNq2vO7ZJGCfskEAo2LW7UjFuE06GOUTc0nHMvfQYxtnmNaAb7L0goZnEaiwN2nkIrid4hlfGmmJHgg==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@seamless-auth/core/-/core-0.25.0.tgz", + "integrity": "sha512-qOYJUNYPKs0UXPg/xbyWG74q16fe7MmXMvNqeV2IRnjl7S1Lsao4rwC06gwz2UZtpuBd3/kbWotCJEo17EXoJw==", "license": "Apache-2.0", "dependencies": { - "@seamless-auth/types": "^0.34.0", + "@seamless-auth/types": "^0.38.0", "@types/jsonwebtoken": "^9.0.10", "jose": "^6.1.3", "jsonwebtoken": "^9.0.2" @@ -1499,13 +1499,13 @@ } }, "node_modules/@seamless-auth/fastify": { - "version": "0.14.0", - "resolved": "https://registry.npmjs.org/@seamless-auth/fastify/-/fastify-0.14.0.tgz", - "integrity": "sha512-mQ+nLu/6DhKKaoNzJQc3ZjR96T7/qq3wTOD9Cm8jK/4miih1ULKtiqFiQ8chgc7AVPyWJcrRxh63cqchO3wSOA==", + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@seamless-auth/fastify/-/fastify-0.16.0.tgz", + "integrity": "sha512-RF8yOEoB0kZhMJrdixkLMsvG7KA3mhEDNgoXV5Kuffz8R59pcFfkM3HuwPgMV2zTK6Dtb68mmYCH3uPLyxMK3Q==", "license": "Apache-2.0", "dependencies": { "@fastify/cookie": "^11.0.2", - "@seamless-auth/core": "^0.23.0", + "@seamless-auth/core": "^0.25.0", "fastify-plugin": "^5.0.1" }, "engines": { @@ -1532,9 +1532,9 @@ "license": "MIT" }, "node_modules/@seamless-auth/types": { - "version": "0.34.0", - "resolved": "https://registry.npmjs.org/@seamless-auth/types/-/types-0.34.0.tgz", - "integrity": "sha512-bTgZkEdpXwAejxwnA1J8vTGAq+FW9L6J4n97q2MI6SqWWIOqXyaF6zMTkzeKqNTvfgF0NIn2DHh9qXL+92FN/A==", + "version": "0.38.0", + "resolved": "https://registry.npmjs.org/@seamless-auth/types/-/types-0.38.0.tgz", + "integrity": "sha512-qALF5GFQEv+9yucQdn179cpTdEMMH/PjtZWAHatWk1qUEOzb1ElOqoBZgC4/THAk6u4aLgDyuDt/dCD/BzloqA==", "license": "Apache-2.0", "dependencies": { "zod": "^4.5.0" @@ -3674,9 +3674,9 @@ } }, "node_modules/jose": { - "version": "6.2.14", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.14.tgz", - "integrity": "sha512-3JasLuki1NRLQvY4wCoC37KZk4qxoBx2k2aFqLxT84IezqW0bkJKnNtxnZj/Fu1xsQivoc/lytxaRHJoGLjJ1A==", + "version": "6.2.15", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.15.tgz", + "integrity": "sha512-qdF6ewcZARtef63fJ9AKKZPgxw+t7USH6HLXYcD4CrdmSvDp79rpJSPFEKc90QBeGS63u1KQtN2MZQHC0yDIzA==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" diff --git a/templates/api/fastify/package.json b/templates/api/fastify/package.json index 7cb54d1..63594d2 100644 --- a/templates/api/fastify/package.json +++ b/templates/api/fastify/package.json @@ -27,7 +27,7 @@ "dependencies": { "@fastify/cookie": "^11.1.2", "@fastify/cors": "^11.3.0", - "@seamless-auth/fastify": "^0.14.0", + "@seamless-auth/fastify": "^0.16.0", "dotenv": "^16.6.1", "fastify": "^5.11.0", "pg": "^8.16.3", diff --git a/templates/api/fastify/src/controllers/reports.controller.ts b/templates/api/fastify/src/controllers/reports.controller.ts new file mode 100644 index 0000000..8840507 --- /dev/null +++ b/templates/api/fastify/src/controllers/reports.controller.ts @@ -0,0 +1,12 @@ +import type { FastifyRequest } from "fastify"; + +export async function getReports(req: FastifyRequest) { + return { + message: "Reports for holders of the reports:read permission.", + reports: [ + { id: "weekly-signups", title: "Weekly sign-ups" }, + { id: "active-sessions", title: "Active sessions" }, + ], + user: req.user, + }; +} diff --git a/templates/api/fastify/src/index.ts b/templates/api/fastify/src/index.ts index 3d95171..aa7e649 100644 --- a/templates/api/fastify/src/index.ts +++ b/templates/api/fastify/src/index.ts @@ -13,6 +13,7 @@ import { connectToDb } from "./db.js"; import { initializeModels } from "../models/index.js"; import beta from "./routes/beta.js"; +import reports from "./routes/reports.js"; import { requireUser } from "./hooks/requireUser.js"; import { assertEnvironment, @@ -169,6 +170,7 @@ await app.register(async (api) => { secured.addHook("preHandler", requireUser(seamlessAuthOptions)); await secured.register(beta, { prefix: "/beta_users" }); + await secured.register(reports, { prefix: "/reports" }); }); }); diff --git a/templates/api/fastify/src/routes/reports.ts b/templates/api/fastify/src/routes/reports.ts new file mode 100644 index 0000000..9af4058 --- /dev/null +++ b/templates/api/fastify/src/routes/reports.ts @@ -0,0 +1,14 @@ +import type { FastifyPluginAsync } from "fastify"; +import { requirePermission } from "@seamless-auth/fastify"; + +import { getReports } from "../controllers/reports.controller.js"; + +// reports:read is the example permission in every Seamless starter, so one role +// mapped to it in the admin console opens this route in whichever starter you +// scaffolded. Permissions need auth API v0.23 or later: an older API issues no +// permissions claim, and this route answers 403 to everyone until then. +const reports: FastifyPluginAsync = async (app) => { + app.get("/", { preHandler: requirePermission("reports:read") }, getReports); +}; + +export default reports; diff --git a/templates/api/gin/AGENTS.md b/templates/api/gin/AGENTS.md index e503ac7..62f5388 100644 --- a/templates/api/gin/AGENTS.md +++ b/templates/api/gin/AGENTS.md @@ -28,6 +28,8 @@ this app is to keep using it, not to rebuild it. - `requireSession` runs `auth.RequireAuth` and stores the `*seamlessauth.User` under `sessionKey`. `requireUser` finds or creates the local user (`internal/db`) under `userKey`. - `requireRole("betaUser")` gates the example route. + - `requirePermission(auth, "reports:read")` gates `/reports`, running the adapter's + `RequirePermission` guard. - `internal/config/config.go`: reads and checks the environment at boot. - `main.go`: startup, migrations and graceful shutdown. @@ -57,6 +59,18 @@ Drop `requireRole(...)` for a route any signed-in user may call. Public routes g `app`. In a handler, the session user is `c.MustGet(sessionKey).(*seamlessauth.User)` (`ID`, `Email`, `Phone`, `Roles`) and the local record is `c.MustGet(userKey)`. +Permissions work the same way, with the adapter's permission guard: + +```go +app.GET("/reports", requirePermission(auth, "reports:read"), reportsContent) +``` + +Permission names match exactly and need auth API v0.23 or later (unreleased); before that the +guard answers 403 to everyone. Granting `reports:read` takes the admin console (Roles page) or the +admin API (`POST /admin/permissions`, then `PUT /admin/roles/instance/betaUser/permissions`), with a +stepped-up `admin:write` session; the README has the steps. Do not reimplement the check by +reading claims yourself. + ## Configuration All configuration is environment variables; see `.env.example`. `APP_ENV`, `PORT`, diff --git a/templates/api/gin/README.md b/templates/api/gin/README.md index 4ced37a..f4063dc 100644 --- a/templates/api/gin/README.md +++ b/templates/api/gin/README.md @@ -22,6 +22,7 @@ starter, so the web and mobile starters work with it unchanged. - Automatic user resolution: every authenticated request finds or creates a local `users` row keyed by the Seamless Auth user id. A new row takes its email and phone from the auth server's profile. - Role-based access: `GET /beta_users` requires the `betaUser` role. +- Permission-based access: `GET /reports` requires the `reports:read` permission, through the adapter's guard. - The session guard accepts the session cookie, or the auth server's access token as a bearer credential (how the mobile starter signs its requests), and blocks cross-site state changes on a cookie session. @@ -103,12 +104,46 @@ started in the console verify. ## Routes -| Method | Path | Description | -| ------ | ------------- | ---------------------------------------------------- | -| GET | `/` | Health check | -| * | `/auth/*` | The Seamless Auth adapter | -| GET | `/console/*` | The admin dashboard, when `SERVE_ADMIN_CONSOLE=true` | -| GET | `/beta_users` | Example route, restricted to the `betaUser` role | +| Method | Path | Description | +| ------ | ------------- | ------------------------------------------------------------------------------------ | +| GET | `/` | Health check | +| * | `/auth/*` | The Seamless Auth adapter | +| GET | `/console/*` | The admin dashboard, when `SERVE_ADMIN_CONSOLE=true` | +| GET | `/beta_users` | Example route, restricted to the `betaUser` role | +| GET | `/reports` | Example route, restricted to the `reports:read` permission (auth API v0.23 or later) | + +## Permissions + +`GET /reports` is guarded by `RequirePermission("reports:read")` from `seamless-auth-go` (run as Gin +middleware by `requirePermission` in [internal/server/server.go](internal/server/server.go)). The +guard reads the permissions on the verified access token, matches them exactly (`reports:write` does +not grant `reports:read`), and answers `403 {"error":"insufficient_permission"}` to a session +without the permission. `reports:read` is the example permission in every Seamless starter, so one +grant opens the matching route and UI whichever starters you combine. + +**This needs Seamless Auth API v0.23 or later, which is not released yet.** An older API issues +access tokens with no `permissions` claim, so on API v0.22 the guard denies every request and +`/reports` answers 403 to everyone. That is the guard failing closed, not a fault in this starter. + +To open `/reports` once your auth server runs v0.23: + +1. Sign in to the admin console as an admin with `admin:write` (the `OWNER_EMAIL` account + `seamless init` sets up has it), with a passkey or authenticator app enrolled: permission + changes need a fresh step-up. +2. Create the permission `reports:read`, and grant it to the `betaUser` instance role. +3. Give your user the `betaUser` role. +4. Sign in again. An access token carries the permissions it was issued with, so a grant reaches + a session at its next token. + +The console's Roles page manages permissions and the roles that grant them. It makes these calls on +the auth server, which you can also script from a stepped-up admin session: + +```text +POST /admin/permissions {"name": "reports:read"} +PUT /admin/roles/instance/betaUser/permissions {"permissions": ["reports:read"]} +``` + +The `PUT` replaces everything the role grants, so list any permissions it already has. ## Checks diff --git a/templates/api/gin/go.mod b/templates/api/gin/go.mod index fd3b49f..73f2ec7 100644 --- a/templates/api/gin/go.mod +++ b/templates/api/gin/go.mod @@ -3,7 +3,7 @@ module seamless-api go 1.26 require ( - github.com/fells-code/seamless-auth-go v0.4.0 + github.com/fells-code/seamless-auth-go v0.6.0 github.com/gin-gonic/gin v1.12.0 github.com/jackc/pgx/v5 v5.11.0 github.com/joho/godotenv v1.5.1 diff --git a/templates/api/gin/go.sum b/templates/api/gin/go.sum index 39a7326..c173ffc 100644 --- a/templates/api/gin/go.sum +++ b/templates/api/gin/go.sum @@ -9,8 +9,8 @@ github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gE github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/fells-code/seamless-auth-go v0.4.0 h1:yik/yalBG/DZ5b1sb+62qpZab0deYJzYNfxqBXISvjo= -github.com/fells-code/seamless-auth-go v0.4.0/go.mod h1:OdfYaQHjp3Iu0Px6oMbxffJ5CzNs4J8Uf0EVD6pP1Z8= +github.com/fells-code/seamless-auth-go v0.6.0 h1:xDD+zodJvFeE7caNe47Z+1fY1z2vD4hgblix3gYQEcI= +github.com/fells-code/seamless-auth-go v0.6.0/go.mod h1:OdfYaQHjp3Iu0Px6oMbxffJ5CzNs4J8Uf0EVD6pP1Z8= github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= diff --git a/templates/api/gin/internal/server/server.go b/templates/api/gin/internal/server/server.go index fbb47fa..e1dbc7c 100644 --- a/templates/api/gin/internal/server/server.go +++ b/templates/api/gin/internal/server/server.go @@ -90,6 +90,12 @@ func New(cfg *config.Config, auth *seamlessauth.Adapter, pool *pgxpool.Pool) *gi profiles := &profileClient{url: strings.TrimRight(cfg.AuthServerURL, "/") + "/users/me", client: &http.Client{Timeout: 10 * time.Second}} app := r.Group("/", requireSession(auth), requireUser(pool, profiles)) app.GET("/beta_users", requireRole("betaUser"), betaContent) + // reports:read is the example permission in every Seamless starter, so one + // role mapped to it in the admin console opens this route in whichever + // starter you scaffolded. Permissions need auth API v0.23 or later: an older + // API issues no permissions claim, and this route answers 403 to everyone + // until then. + app.GET("/reports", requirePermission(auth, "reports:read"), reportsContent) return r } @@ -102,6 +108,17 @@ func betaContent(c *gin.Context) { }) } +func reportsContent(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "message": "Reports for holders of the reports:read permission.", + "reports": []gin.H{ + {"id": "weekly-signups", "title": "Weekly sign-ups"}, + {"id": "active-sessions", "title": "Active sessions"}, + }, + "user": c.MustGet(userKey), + }) +} + const ( sessionKey = "seamlessUser" userKey = "appUser" @@ -110,9 +127,21 @@ const ( // requireSession runs the adapter's guard: 401 without a session, 403 for a // cross-site state change on a cookie session. func requireSession(auth *seamlessauth.Adapter) gin.HandlerFunc { + return adapterGuard(auth.RequireAuth) +} + +// requirePermission runs the adapter's permission guard, which also answers 403 +// insufficient_permission unless the session holds one of permissions. +func requirePermission(auth *seamlessauth.Adapter, permissions ...string) gin.HandlerFunc { + return adapterGuard(auth.RequirePermission(permissions...)) +} + +// adapterGuard runs one of the adapter's net/http guards as Gin middleware. A +// guard that refuses has already answered the request. +func adapterGuard(guard func(http.Handler) http.Handler) gin.HandlerFunc { return func(c *gin.Context) { var user *seamlessauth.User - auth.RequireAuth(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + guard(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { user, _ = seamlessauth.UserFromContext(r.Context()) c.Request = r })).ServeHTTP(c.Writer, c.Request) diff --git a/templates/api/gin/internal/server/server_test.go b/templates/api/gin/internal/server/server_test.go index 1d6d552..efea7f5 100644 --- a/templates/api/gin/internal/server/server_test.go +++ b/templates/api/gin/internal/server/server_test.go @@ -96,11 +96,13 @@ func TestTheConsoleIsServedWhenEnabled(t *testing.T) { } func TestAppRoutesNeedASession(t *testing.T) { - rec := do(router(t, nil), "GET", "/beta_users", nil, "") - var body map[string]any - _ = json.Unmarshal(rec.Body.Bytes(), &body) - if rec.Code != 401 || body["error"] != "unauthenticated" { - t.Fatalf("%d %q", rec.Code, rec.Body.String()) + for _, target := range []string{"/beta_users", "/reports"} { + rec := do(router(t, nil), "GET", target, nil, "") + var body map[string]any + _ = json.Unmarshal(rec.Body.Bytes(), &body) + if rec.Code != 401 || body["error"] != "unauthenticated" { + t.Fatalf("%s: %d %q", target, rec.Code, rec.Body.String()) + } } } diff --git a/templates/fullstack/nextjs/AGENTS.md b/templates/fullstack/nextjs/AGENTS.md index ee355e1..6f4c553 100644 --- a/templates/fullstack/nextjs/AGENTS.md +++ b/templates/fullstack/nextjs/AGENTS.md @@ -26,6 +26,8 @@ when extending this app is to keep using them, not to rebuild them. `src/components/Providers.tsx`. - `src/proxy.ts`: redirects signed-out visitors to `/login` with `hasSeamlessSession`, for the paths in its `matcher`. +- `src/app/api/reports/route.ts`: an API route guarded with `requirePermission("reports:read", ...)`; + `src/components/ReportsAccess.tsx` shows it with `usePermissions().hasPermission`, for display only. - `src/app/api/beta-users/route.ts`: an API route guarded on the server with `getSeamlessClaims` and `hasScopedRole`. - `src/components/SignIn.tsx` and `src/components/VerifyMagicLink.tsx`: the sign-in UI, built on @@ -69,6 +71,25 @@ still check the claims in any data it loads. In client components, the current u `const { user, isAuthenticated } = useAuth();`, and `useAuthorizedFetch()` calls your own API routes (see `src/components/BetaAccess.tsx`). Roles are managed in the Seamless admin console. +Permissions work the same way, with the adapter's permission guard: + +```ts +export async function GET(request: Request) { + const route = requirePermission( + "reports:read", + handler, + requireAuthConfig().handler, + ); + return route(request, undefined); +} +``` + +Permission names match exactly and need auth API v0.23 or later (unreleased); before that the +guard answers 403 to everyone. Granting `reports:read` takes the admin console (Roles page) or the +admin API (`POST /admin/permissions`, then `PUT /admin/roles/instance/betaUser/permissions`), with a +stepped-up `admin:write` session; the README has the steps. Do not reimplement the check by +reading claims yourself. + ## Configuration All configuration is environment variables; see `.env.example`: `AUTH_SERVER_URL`, diff --git a/templates/fullstack/nextjs/README.md b/templates/fullstack/nextjs/README.md index 9b41769..55b0844 100644 --- a/templates/fullstack/nextjs/README.md +++ b/templates/fullstack/nextjs/README.md @@ -88,6 +88,42 @@ the `betaUser` role. Grant it from the Seamless admin console (see Admin console), then sign in again so the new role is in your session. +## Permissions + +`GET /api/reports` is guarded by `requirePermission("reports:read", ...)` from +`@seamless-auth/nextjs`, in [src/app/api/reports/route.ts](src/app/api/reports/route.ts). The Beta +page shows the reports through `src/components/ReportsAccess.tsx` only when +`usePermissions().hasPermission("reports:read")`, which is for display: the route is what enforces. +The guard reads the permissions on the verified access token, matches them exactly (`reports:write` +does not grant `reports:read`), and answers `403 {"error":"insufficient_permission"}` to a session +without the permission. `reports:read` is the example permission in every Seamless starter, so one +grant opens the matching route and UI whichever starters you combine. + +**This needs Seamless Auth API v0.23 or later, which is not released yet.** An older API issues +access tokens with no `permissions` claim, so on API v0.22 the guard denies every request and +`/api/reports` answers 403 to everyone. That is the guard failing closed, not a fault in this +starter. + +To open `/api/reports` once your auth server runs v0.23: + +1. Sign in to the admin console as an admin with `admin:write` (the `OWNER_EMAIL` account + `seamless init` sets up has it), with a passkey or authenticator app enrolled: permission + changes need a fresh step-up. +2. Create the permission `reports:read`, and grant it to the `betaUser` instance role. +3. Give your user the `betaUser` role. +4. Sign in again. An access token carries the permissions it was issued with, so a grant reaches + a session at its next token. + +The console's Roles page manages permissions and the roles that grant them. It makes these calls on +the auth server, which you can also script from a stepped-up admin session: + +```text +POST /admin/permissions {"name": "reports:read"} +PUT /admin/roles/instance/betaUser/permissions {"permissions": ["reports:read"]} +``` + +The `PUT` replaces everything the role grants, so list any permissions it already has. + ## Admin console With `SERVE_ADMIN_CONSOLE=true` (the default, and what diff --git a/templates/fullstack/nextjs/package-lock.json b/templates/fullstack/nextjs/package-lock.json index b881989..6dbf0f3 100644 --- a/templates/fullstack/nextjs/package-lock.json +++ b/templates/fullstack/nextjs/package-lock.json @@ -9,8 +9,8 @@ "version": "0.1.0", "license": "Apache-2.0", "dependencies": { - "@seamless-auth/nextjs": "^0.7.0", - "@seamless-auth/react": "^0.19.2", + "@seamless-auth/nextjs": "^0.9.0", + "@seamless-auth/react": "^0.20.0", "next": "^16.3.8", "react": "^19.2.0", "react-dom": "^19.2.0" @@ -2516,12 +2516,12 @@ "license": "MIT" }, "node_modules/@seamless-auth/client": { - "version": "0.8.2", - "resolved": "https://registry.npmjs.org/@seamless-auth/client/-/client-0.8.2.tgz", - "integrity": "sha512-fFpEcoZ2gLLvAFXwmtPQw7kwRzEfY6yYAHtryIpnrQdXQKIeROLyOv15fOECLtS/zDhF96lmV/ZlMwMpqNsbZQ==", + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@seamless-auth/client/-/client-0.9.0.tgz", + "integrity": "sha512-CavI+GTQvt/lreVzaFuATWdyg2SqqWVsVXneUgKTKkshHywgkXb4/8V0RTvhfSqRxIn+YRBMlsGbUq4RdSDwSg==", "license": "Apache-2.0", "dependencies": { - "@seamless-auth/types": "^0.34.0", + "@seamless-auth/types": "^0.38.0", "@simplewebauthn/browser": "^13.1.0", "libphonenumber-js": "^1.12.7" }, @@ -2531,12 +2531,12 @@ } }, "node_modules/@seamless-auth/core": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@seamless-auth/core/-/core-0.23.0.tgz", - "integrity": "sha512-KqVgNRyTNq2vO7ZJGCfskEAo2LW7UjFuE06GOUTc0nHMvfQYxtnmNaAb7L0goZnEaiwN2nkIrid4hlfGmmJHgg==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@seamless-auth/core/-/core-0.25.0.tgz", + "integrity": "sha512-qOYJUNYPKs0UXPg/xbyWG74q16fe7MmXMvNqeV2IRnjl7S1Lsao4rwC06gwz2UZtpuBd3/kbWotCJEo17EXoJw==", "license": "Apache-2.0", "dependencies": { - "@seamless-auth/types": "^0.34.0", + "@seamless-auth/types": "^0.38.0", "@types/jsonwebtoken": "^9.0.10", "jose": "^6.1.3", "jsonwebtoken": "^9.0.2" @@ -2546,13 +2546,13 @@ } }, "node_modules/@seamless-auth/nextjs": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/@seamless-auth/nextjs/-/nextjs-0.7.0.tgz", - "integrity": "sha512-sDyUPxUGfydWZbrJTlKts2ctqAkkABars7ECcv0ePcjTDh3ni1UK1ernGz5W4e0Vk7zgZxu1/MS8IsvDFkOfnQ==", + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@seamless-auth/nextjs/-/nextjs-0.9.0.tgz", + "integrity": "sha512-OIpjuIDxG285dJ/RmiTcBCFhWSPQrCyxtRVn1M1KBGDTLSU4aytenlZQYLSXdtZCIP2gY88/VxBbKGPaBmkO3g==", "license": "Apache-2.0", "dependencies": { - "@seamless-auth/core": "^0.23.0", - "@seamless-auth/types": "^0.34.0", + "@seamless-auth/core": "^0.25.0", + "@seamless-auth/types": "^0.38.0", "cookie": "^1.1.1" }, "engines": { @@ -2560,13 +2560,13 @@ } }, "node_modules/@seamless-auth/react": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/@seamless-auth/react/-/react-0.19.2.tgz", - "integrity": "sha512-qlLpGYfq4knPV5FVH3aBPHra+B//QV2H7lMMXSgwvYpZxbngSPowLqAxjGk8bqHAWr1BvvX7z3tk5t/2BS5SCA==", + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@seamless-auth/react/-/react-0.20.0.tgz", + "integrity": "sha512-AwWQILnQaEU39MmNoZ3k1bC3jqAFhhZGf9rbmUzhC8dG7wx1295sIvTT+DPPFrSowO47f5Y5PYpTWhCBoshmfg==", "license": "Apache-2.0", "dependencies": { - "@seamless-auth/client": "^0.8.2", - "@seamless-auth/types": "^0.34.0", + "@seamless-auth/client": "^0.9.0", + "@seamless-auth/types": "^0.38.0", "@simplewebauthn/browser": "^13.1.0", "libphonenumber-js": "^1.12.7" }, @@ -2586,9 +2586,9 @@ } }, "node_modules/@seamless-auth/types": { - "version": "0.34.0", - "resolved": "https://registry.npmjs.org/@seamless-auth/types/-/types-0.34.0.tgz", - "integrity": "sha512-bTgZkEdpXwAejxwnA1J8vTGAq+FW9L6J4n97q2MI6SqWWIOqXyaF6zMTkzeKqNTvfgF0NIn2DHh9qXL+92FN/A==", + "version": "0.38.0", + "resolved": "https://registry.npmjs.org/@seamless-auth/types/-/types-0.38.0.tgz", + "integrity": "sha512-qALF5GFQEv+9yucQdn179cpTdEMMH/PjtZWAHatWk1qUEOzb1ElOqoBZgC4/THAk6u4aLgDyuDt/dCD/BzloqA==", "license": "Apache-2.0", "dependencies": { "zod": "^4.5.0" @@ -6855,9 +6855,9 @@ } }, "node_modules/jose": { - "version": "6.2.14", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.14.tgz", - "integrity": "sha512-3JasLuki1NRLQvY4wCoC37KZk4qxoBx2k2aFqLxT84IezqW0bkJKnNtxnZj/Fu1xsQivoc/lytxaRHJoGLjJ1A==", + "version": "6.2.15", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.15.tgz", + "integrity": "sha512-qdF6ewcZARtef63fJ9AKKZPgxw+t7USH6HLXYcD4CrdmSvDp79rpJSPFEKc90QBeGS63u1KQtN2MZQHC0yDIzA==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" @@ -7003,9 +7003,9 @@ } }, "node_modules/jsonwebtoken/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "version": "7.8.6", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.6.tgz", + "integrity": "sha512-9lpSSU4Mly3mrtruwLs1eLK6+gzbzqeBFwRNjIjE+9LuDpWdYctrQlQ3ej1Ilgi7gknpsTRnPoLP8DpCHqHIJw==", "license": "ISC", "bin": { "semver": "bin/semver.js" diff --git a/templates/fullstack/nextjs/package.json b/templates/fullstack/nextjs/package.json index 552b9e5..d36338f 100644 --- a/templates/fullstack/nextjs/package.json +++ b/templates/fullstack/nextjs/package.json @@ -24,8 +24,8 @@ }, "author": "Fells Code LLC", "dependencies": { - "@seamless-auth/nextjs": "^0.7.0", - "@seamless-auth/react": "^0.19.2", + "@seamless-auth/nextjs": "^0.9.0", + "@seamless-auth/react": "^0.20.0", "next": "^16.3.8", "react": "^19.2.0", "react-dom": "^19.2.0" diff --git a/templates/fullstack/nextjs/src/app/(app)/beta/page.tsx b/templates/fullstack/nextjs/src/app/(app)/beta/page.tsx index b8f9b32..b60c3ed 100644 --- a/templates/fullstack/nextjs/src/app/(app)/beta/page.tsx +++ b/templates/fullstack/nextjs/src/app/(app)/beta/page.tsx @@ -1,5 +1,11 @@ import BetaAccess from "@/components/BetaAccess"; +import ReportsAccess from "@/components/ReportsAccess"; export default function BetaPage() { - return ; + return ( +
+ + +
+ ); } diff --git a/templates/fullstack/nextjs/src/app/api/reports/route.test.ts b/templates/fullstack/nextjs/src/app/api/reports/route.test.ts new file mode 100644 index 0000000..9de8856 --- /dev/null +++ b/templates/fullstack/nextjs/src/app/api/reports/route.test.ts @@ -0,0 +1,22 @@ +// @vitest-environment node +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { GET } from "./route"; + +describe("GET /api/reports", () => { + beforeEach(() => { + vi.stubEnv("AUTH_SERVER_URL", "http://localhost:5312"); + vi.stubEnv("COOKIE_SIGNING_KEY", "x".repeat(32)); + vi.stubEnv("SEAMLESS_SERVICE_TOKEN", "service-token"); + vi.stubEnv("JWKS_KID", "dev-main"); + }); + + it("answers 401 without a session", async () => { + const response = await GET( + new Request("http://localhost:3000/api/reports"), + ); + + expect(response.status).toBe(401); + expect(await response.json()).toEqual({ error: "unauthenticated" }); + }); +}); diff --git a/templates/fullstack/nextjs/src/app/api/reports/route.ts b/templates/fullstack/nextjs/src/app/api/reports/route.ts new file mode 100644 index 0000000..ec25fbf --- /dev/null +++ b/templates/fullstack/nextjs/src/app/api/reports/route.ts @@ -0,0 +1,37 @@ +import { requirePermission } from "@seamless-auth/nextjs"; + +import { requireAuthConfig } from "@/lib/config"; + +// Stands in for the application's own data, as the beta list does. +const REPORTS = [ + { id: "weekly-signups", title: "Weekly sign-ups" }, + { id: "active-sessions", title: "Active sessions" }, +]; + +/** + * An application route guarded by a permission. `requirePermission` verifies + * the session and answers 403 `insufficient_permission` unless its access token + * carries `reports:read`; the panel the page shows is only a reflection of it. + * + * reports:read is the example permission in every Seamless starter, so one role + * mapped to it in the admin console opens this route in whichever starter you + * scaffolded. Permissions need auth API v0.23 or later: an older API issues no + * permissions claim, and this route answers 403 to everyone until then. + * + * The guard is built per request because the configuration is read per + * request (see src/lib/config.ts). + */ +export async function GET(request: Request) { + const route = requirePermission( + "reports:read", + (_request, _context, user) => + Response.json({ + message: "Reports for holders of the reports:read permission.", + reports: REPORTS, + user: { id: user.id }, + }), + requireAuthConfig().handler, + ); + + return route(request, undefined); +} diff --git a/templates/fullstack/nextjs/src/components/ReportsAccess.tsx b/templates/fullstack/nextjs/src/components/ReportsAccess.tsx new file mode 100644 index 0000000..0afbb17 --- /dev/null +++ b/templates/fullstack/nextjs/src/components/ReportsAccess.tsx @@ -0,0 +1,88 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useAuthorizedFetch, usePermissions } from "@seamless-auth/react"; + +type Report = { id: string; title: string }; + +type State = + | { kind: "loading" } + | { kind: "loaded"; reports: Report[] } + | { kind: "refused"; status: number } + | { kind: "failed" }; + +/** + * Shown only to a session that holds `reports:read`. Hiding it is a courtesy: + * `requirePermission` on /api/reports is what enforces, and it refuses anyone + * without the permission whatever this component renders. + */ +export default function ReportsAccess() { + const { hasPermission, loading } = usePermissions(); + const authorizedFetch = useAuthorizedFetch(); + const [state, setState] = useState({ kind: "loading" }); + const canReadReports = hasPermission("reports:read"); + + useEffect(() => { + if (!canReadReports) return; + let active = true; + + void (async () => { + try { + const response = await authorizedFetch("/api/reports"); + if (!active) return; + setState( + response.ok + ? { + kind: "loaded", + reports: ((await response.json()) as { reports: Report[] }) + .reports, + } + : { kind: "refused", status: response.status }, + ); + } catch { + if (active) setState({ kind: "failed" }); + } + })(); + + return () => { + active = false; + }; + }, [authorizedFetch, canReadReports]); + + if (loading) return null; + + return ( +
+
+

Reports

+

+ {canReadReports + ? "You hold the reports:read permission." + : "You do not hold the reports:read permission. It needs Seamless Auth API v0.23 or later and a role that grants it; see the README."} +

+
+ + {canReadReports && state.kind === "loading" && ( +

Loading…

+ )} + {canReadReports && state.kind === "refused" && ( +

+ The server answered {state.status}. Permissions in the browser come + from the last session read, so the server can know better. +

+ )} + {canReadReports && state.kind === "failed" && ( +

The request did not complete.

+ )} + {canReadReports && state.kind === "loaded" && ( +
    + {state.reports.map((report) => ( +
  • + {report.title} +
  • + ))} +
+ )} +
+ ); +} diff --git a/templates/mobile/expo/AGENTS.md b/templates/mobile/expo/AGENTS.md index 4bd3c59..f8b7912 100644 --- a/templates/mobile/expo/AGENTS.md +++ b/templates/mobile/expo/AGENTS.md @@ -55,6 +55,12 @@ const authorizedFetch = useAuthorizedFetch(); const response = await authorizedFetch("/beta_users"); ``` +To show UI by permission, use `hasPermission` from the SDK, as `app/(app)/index.tsx` does for +`reports:read`. It is for display only: the API's permission guard enforces. Permissions need auth +API v0.23 or later (unreleased). Granting one takes the admin console's Roles page, or +`POST /admin/permissions` then `PUT /admin/roles/instance/betaUser/permissions` from a stepped-up +`admin:write` session; the README has the steps. + ## Configuration - `EXPO_PUBLIC_API_URL` in `.env` (see `.env.example`): the companion API origin, inlined at build diff --git a/templates/mobile/expo/README.md b/templates/mobile/expo/README.md index 1541e56..536f02a 100644 --- a/templates/mobile/expo/README.md +++ b/templates/mobile/expo/README.md @@ -96,6 +96,40 @@ add `https://` plus the Android origin to its `ORIGINS` (keep the web orig - A tunnel that changes hostname on restart changes the RP ID, which invalidates every passkey registered against the old one. Use a stable domain. +## Permissions + +The home screen offers a Load reports button that calls `GET /reports` (`app/(app)/index.tsx`, with +`hasPermission` from `@seamless-auth/react-native`) only when the signed-in user holds the +`reports:read` permission. That check only decides what to show: the API's permission guard on `GET +/reports` is what enforces, and it refuses a session without the permission whatever the UI renders. +The permissions come from the last session read, so a new grant shows up after the next sign-in. +`reports:read` is the example permission in every Seamless starter, and the API starters serve `GET +/reports` behind it. + +**This needs Seamless Auth API v0.23 or later, which is not released yet.** An older API issues +access tokens with no `permissions` claim, so the user carries no permissions on API v0.22: the UI +stays hidden and the API answers 403 to everyone. + +To grant it once your auth server runs v0.23: + +1. Sign in to the admin console as an admin with `admin:write` (the `OWNER_EMAIL` account + `seamless init` sets up has it), with a passkey or authenticator app enrolled: permission + changes need a fresh step-up. +2. Create the permission `reports:read`, and grant it to the `betaUser` instance role. +3. Give your user the `betaUser` role. +4. Sign in again. An access token carries the permissions it was issued with, so a grant reaches + a session at its next token. + +The console's Roles page manages permissions and the roles that grant them. It makes these calls on +the auth server, which you can also script from a stepped-up admin session: + +```text +POST /admin/permissions {"name": "reports:read"} +PUT /admin/roles/instance/betaUser/permissions {"permissions": ["reports:read"]} +``` + +The `PUT` replaces everything the role grants, so list any permissions it already has. + ## Project Layout ``` diff --git a/templates/mobile/expo/app/(app)/index.tsx b/templates/mobile/expo/app/(app)/index.tsx index 1266e19..005f2e0 100644 --- a/templates/mobile/expo/app/(app)/index.tsx +++ b/templates/mobile/expo/app/(app)/index.tsx @@ -1,12 +1,17 @@ -import { useAuth, useAuthorizedFetch } from "@seamless-auth/react-native"; +import { + hasPermission, + useAuth, + useAuthorizedFetch, +} from "@seamless-auth/react-native"; import React, { useState } from "react"; import { Body, Button, ErrorNote, Screen, Title } from "@/ui"; /** - * Signed in. Shows the session and calls a route on the companion API that - * sits behind `requireAuth`, carrying the access token with one refresh on a - * 401, which is what `useAuthorizedFetch` does. + * Signed in. Shows the session and calls routes on the companion API that + * sit behind `requireAuth`, carrying the access token with one refresh on a + * 401, which is what `useAuthorizedFetch` does. /reports also needs the + * reports:read permission. */ export default function Home() { const { user, credentials, logout, hasScopedRole } = useAuth(); @@ -14,10 +19,14 @@ export default function Home() { const [result, setResult] = useState(""); const [error, setError] = useState(""); - const callApi = async () => { + // The API guards /reports with reports:read and refuses anyone without it. + // Checking here only decides whether to offer the button. + const canReadReports = hasPermission(user, "reports:read"); + + const callApi = async (path: string) => { setError(""); try { - const response = await authorizedFetch("/beta_users"); + const response = await authorizedFetch(path); if (!response.ok) { setError(`The API answered ${response.status}.`); return; @@ -38,9 +47,16 @@ export default function Home() {