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
5 changes: 5 additions & 0 deletions .changeset/permission-guard-conformance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"seamless-cli": patch
---

The adapter conformance suite covers permission guards, under the pending contract `permission-guards`. It holds a reference app's new `/api/permissions` routes to one behavior: exact matching, any-of and all-of, organization scope bound to the route, removal from an organization reaching the guard at the next token, and the live read when a token omits its permissions. The cases are skipped unless a run opts in with `SEAMLESS_VERIFY_CONTRACTS`, because they need the auth API at v0.23. The Express and Fastify reference apps add the routes only when the installed adapter has `requirePermission`.
8 changes: 4 additions & 4 deletions package-lock.json

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

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@
"dependencies": {
"@clack/prompts": "^1.0.1",
"@napi-rs/keyring": "^1.3.0",
"@seamless-auth/types": "^0.37.0",
"@seamless-auth/types": "^0.38.0",
"adm-zip": "^0.6.1",
"jose": "^6.2.15",
"kleur": "^4.1.5",
Expand Down
15 changes: 15 additions & 0 deletions verify/CONFORMANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ everyone.
| Contract | Ticket | What it holds an adapter to |
| --- | --- | --- |
| `content-type-gate` | fells-code/seamless-auth-api#558 | A body-less unsafe request to `/auth/*` without `Content-Type: application/json` or `x-seamless-auth-transport` answers `415 unsupported_media_type` |
| `permission-guards` | fells-code/seamless-auth-server#218 | The `/api/permissions` routes guard by permission. Needs the auth API at v0.23 |

## The reference app

Expand All @@ -56,6 +57,10 @@ everyone.
| `GET /` | Any 2xx. The harness waits on it before running |
| `/auth/*` | The adapter |
| `GET /api/me` | An app route behind the adapter's own guard. `200 { "id": "<user id>" }` for a valid session cookie or `Authorization: Bearer <access token>`, `401` otherwise |
| `GET /api/permissions/any` | Pending `permission-guards`. Any of `conformance:read`, `conformance:other`. `200 { "id": "<user id>" }` when allowed |
| `GET /api/permissions/all` | Pending `permission-guards`. All of `conformance:read`, `conformance:write` |
| `GET /api/permissions/org` | Pending `permission-guards`. Organization scope, `conformance:org` |
| `GET /api/permissions/org/:organizationId` | Pending `permission-guards`. Organization scope bound to the route, `conformance:org` |
| `GET /__captured/:recipient` | The most recent message the adapter delivered to `recipient` (an email address or phone number): `{ "token": "<code or link token>", "magicLinkUrl"?: "..." }`, or `null` |

`/__captured` is a test hook, never part of a real deployment. The adapter must ask the
Expand Down Expand Up @@ -104,6 +109,16 @@ session.
sign-in flow's ephemeral cookie presented as the access cookie, and the signed-in user's
id with a valid one.

**Permission guards** (pending `permission-guards`).
- No session answers `401`. A session without the permission answers
`403 { "error": "insufficient_permission" }`, naming nothing.
- Matching is exact: `conformance:write` does not satisfy `conformance:read`.
- By default a permission counts from `permissions` or `org_permissions`. Organization scope
counts only `org_permissions` and needs an active organization. The bound form also needs the
route's organization id to equal `org_id`.
- When a token carries `permissions_omitted: true`, the guard reads
`GET /users/me/permissions` with the caller's token and applies the same rules.

**Request guards** (cookie transport).
- A request with `Sec-Fetch-Site: cross-site` and any method but `GET`, `HEAD` or
`OPTIONS` answers `403 cross_site_request_blocked` and reaches nothing: a cross-site
Expand Down
42 changes: 41 additions & 1 deletion verify/adapter-app/server.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import cookieParser from "cookie-parser";
import cors from "cors";
import express from "express";
import createSeamlessAuthServer, { requireAuth } from "@seamless-auth/express";
import createSeamlessAuthServer, * as seamless from "@seamless-auth/express";

const { requireAuth, requirePermission } = seamless;

// The adapter strips OTP/magic-link secrets before responding to the browser, so
// the conformance harness can't read codes from responses. These handlers receive
Expand Down Expand Up @@ -65,6 +67,44 @@ app.get(
(req, res) => res.json({ id: req.user.id }),
);

// The `permission-guards` pending contract. Only when the adapter has the guard, because a
// released-mode run installs the published package, which may predate it. Becomes
// unconditional once the contract leaves the pending list.
if (requirePermission) {
const session = requireAuth({
cookieSecret: process.env.COOKIE_SIGNING_KEY,
authServerUrl: process.env.AUTH_SERVER_URL,
authServerIssuer: ISSUER,
audience: ISSUER,
});
const me = (req, res) => res.json({ id: req.user.id });

app.get(
"/api/permissions/any",
session,
requirePermission(["conformance:read", "conformance:other"]),
me,
);
app.get(
"/api/permissions/all",
session,
requirePermission(["conformance:read", "conformance:write"], { match: "all" }),
me,
);
app.get(
"/api/permissions/org",
session,
requirePermission("conformance:org", { organization: true }),
me,
);
app.get(
"/api/permissions/org/:organizationId",
session,
requirePermission("conformance:org", { organizationParam: "organizationId" }),
me,
);
}

app.use(
"/auth",
createSeamlessAuthServer({
Expand Down
35 changes: 34 additions & 1 deletion verify/adapter-fastify-app/server.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import cors from "@fastify/cors";
import Fastify from "fastify";
import seamlessAuth, { requireAuth } from "@seamless-auth/fastify";
import seamlessAuth, * as seamless from "@seamless-auth/fastify";

const { requireAuth, requirePermission } = seamless;

// The Fastify twin of adapter-app/server.mjs. Same routes, same env contract,
// same capture transport: the point of this service is that a spec cannot tell
Expand Down Expand Up @@ -67,6 +69,37 @@ app.get(
async (req) => ({ id: req.user.id }),
);

// The `permission-guards` pending contract. Only when the adapter has the guard, because a
// released-mode run installs the published package, which may predate it. Becomes
// unconditional once the contract leaves the pending list.
if (requirePermission) {
const session = requireAuth({
cookieSecret: process.env.COOKIE_SIGNING_KEY,
authServerUrl: process.env.AUTH_SERVER_URL,
authServerIssuer: ISSUER,
audience: ISSUER,
});
const me = async (req) => ({ id: req.user.id });
const guarded = (path, guard) => app.get(path, { preHandler: [session, guard] }, me);

guarded(
"/api/permissions/any",
requirePermission(["conformance:read", "conformance:other"]),
);
guarded(
"/api/permissions/all",
requirePermission(["conformance:read", "conformance:write"], { match: "all" }),
);
guarded(
"/api/permissions/org",
requirePermission("conformance:org", { organization: true }),
);
guarded(
"/api/permissions/org/:organizationId",
requirePermission("conformance:org", { organizationParam: "organizationId" }),
);
}

// Registered under a prefix, which is how the plugin scopes its cookie and origin
// hooks. @fastify/cookie comes with the plugin, so it is not registered here.
// There is no `issuer` option on this adapter (the Express one takes one); the
Expand Down
Loading
Loading