Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/starter-permission-guards.md
Original file line number Diff line number Diff line change
@@ -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 add a dedicated `reportViewer` role, create the permission and grant it to that 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": "<code>"}` instead of `{"detail": "<code>"}`.
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions shared/react-app/README.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
51 changes: 51 additions & 0 deletions shared/react-app/components/ReportsPanel.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof import("../lib/api")>()),
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(<ReportsPanel />);

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(<ReportsPanel />);

expect(await screen.findByText("Weekly sign-ups")).toBeInTheDocument();
expect(apiFetch).toHaveBeenCalledWith("/reports");
});
});
82 changes: 82 additions & 0 deletions shared/react-app/components/ReportsPanel.tsx
Original file line number Diff line number Diff line change
@@ -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<Report[] | null>(null);
const [error, setError] = useState<string | null>(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 (
<p className="text-sm text-ink-muted">
This user does not hold the{" "}
<code className="px-1 py-0.5 rounded bg-line">reports:read</code>{" "}
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.
</p>
);
}

return (
<div className="space-y-3">
<p className="text-sm text-ink-muted">
This user holds{" "}
<code className="px-1 py-0.5 rounded bg-line">reports:read</code>, so
the reports from the API show here.
</p>

{error && <p className="text-sm text-red-700">{error}</p>}

{reports && (
<ul className="space-y-1">
{reports.map((report) => (
<li key={report.id} className="text-sm text-ink">
{report.title}
</li>
))}
</ul>
)}
</div>
);
}
8 changes: 8 additions & 0 deletions shared/react-app/sync.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
15 changes: 15 additions & 0 deletions templates/api/axum/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<db::User>` for the local record, or
`Extension<seamless_auth::User>` 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/reportViewer/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`,
Expand Down
4 changes: 2 additions & 2 deletions templates/api/axum/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion templates/api/axum/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
50 changes: 44 additions & 6 deletions templates/api/axum/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -105,12 +106,49 @@ 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. Add a `reportViewer` role to the instance's `available_roles`, on the console's system config page
or with `seamless config set available_roles`. That replaces the whole list, so include the
roles you already have (`seamless config get available_roles` shows them).
3. Create the permission `reports:read`, and grant it to the `reportViewer` instance role.
4. Give your user the `reportViewer` role.
5. 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 needs console v0.13.0 or later.
The page 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/reportViewer/permissions {"permissions": ["reports:read"]}
```

The `PUT` replaces everything the role grants, so list any permissions it already has.

## Checks

Expand Down
31 changes: 28 additions & 3 deletions templates/api/axum/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -108,6 +120,17 @@ async fn beta_content(Extension(user): Extension<db::User>) -> Json<serde_json::
}))
}

async fn reports_content(Extension(user): Extension<db::User>) -> Json<serde_json::Value> {
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
Expand Down Expand Up @@ -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]
Expand Down
13 changes: 13 additions & 0 deletions templates/api/express/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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/reportViewer/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`.
Expand Down
Loading
Loading