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
10 changes: 10 additions & 0 deletions .changeset/nextjs-route-guards.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@seamless-auth/nextjs": minor
"@seamless-auth/core": minor
---

Next.js: add `requireAuth(handler, options)` and `requireRole(roles, handler, options)` to guard App Router route handlers. They accept the access cookie or the auth API's access token in `Authorization: Bearer`, verify the token against the auth API's key set (so roles come from a token the API signed), refuse recovery sessions unless `allowRecoverySession` is set, apply the origin check to cookie requests, and never refresh. Refusals answer `401 {"error":"unauthenticated"}`, `403 {"error":"enrollment_required"}`, `403 {"error":"cross_site_request_blocked"}` or `403 {"error":"insufficient_role"}`, without naming any roles. The user handed to the route carries `organizationId` from the token's `org_id`. A role guard with no roles throws at setup.

Next.js: add a `trustedProxies` option (addresses or CIDR ranges) that reads the client IP from `X-Forwarded-For`, walking from the right past your proxies, when `resolveClientIp` is not given. Nothing is forwarded when neither is set, as before, but the handler now logs one warning at startup saying so. `getSeamlessSession` takes the request `headers` with `trustedProxies` or `resolveClientIp` and forwards the client IP, and reads the user agent from them when `userAgent` is not given.

Core: `authenticateBearer` and `authenticateRequest` (with bearer verification on) set `organizationId` on the user from the verified access token's `org_id`. The new `GuardUser` type describes it.
79 changes: 79 additions & 0 deletions .github/workflows/conformance.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,82 @@ jobs:
uses: fells-code/seamless-cli/.github/workflows/verify-conformance.yml@1ff9552d68d3d74096992a83d931acc0233edab2 # v0.26.0
with:
server-ref: ${{ github.event.pull_request.head.sha }}

# The seamless-cli matrix above runs the Express and Fastify reference apps it
# builds itself. Next.js has no reference app there, so this job runs the same
# adapter specs against conformance/nextjs-refapp through --adapter-url, the
# way the Go, Python and Rust adapters run them from their own repositories.
nextjs:
name: adapter conformance (Next.js)
runs-on: ubuntu-latest
timeout-minutes: 30
env:
HUSKY: '0'
NEXT_TELEMETRY_DISABLED: '1'
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
path: seamless-auth-server

# The same CLI the matrix above pins, so both run one version of the specs.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: fells-code/seamless-cli
ref: 1ff9552d68d3d74096992a83d931acc0233edab2 # v0.26.0
path: seamless-cli

- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: fells-code/seamless-auth-api
path: seamless-auth-api

- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
with:
version: 10.33.0

- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version-file: seamless-auth-server/.nvmrc
cache: pnpm
cache-dependency-path: seamless-auth-server/pnpm-lock.yaml

- name: Build the CLI
working-directory: seamless-cli
run: npm ci && npm run build

- name: Build the reference app
working-directory: seamless-auth-server
run: |
pnpm install --frozen-lockfile
pnpm --filter @seamless-auth/core build
pnpm --filter @seamless-auth/nextjs build
pnpm --filter seamless-nextjs-refapp build:refapp

# Values from verify/CONFORMANCE.md in seamless-cli. The app runs on the
# host and reaches the API through its published port, while the API signs
# as its in-network ISSUER.
- name: Start the reference app
working-directory: seamless-auth-server/conformance/nextjs-refapp
env:
AUTH_SERVER_URL: http://localhost:5312
AUTH_SERVER_ISSUER: http://auth-api:5312
SEAMLESS_SERVICE_TOKEN: verify-dev-service-token-not-a-real-secret
COOKIE_SIGNING_KEY: verify-dev-service-token-not-a-real-secret
JWKS_KID: dev-main
run: nohup pnpm start:refapp -p 3002 > refapp.log 2>&1 &

- name: Conformance
env:
SEAMLESS_API_DIR: ${{ github.workspace }}/seamless-auth-api
run: node seamless-cli/dist/index.js verify --adapter-url=http://localhost:3002

- name: Reference app log
if: always()
run: cat seamless-auth-server/conformance/nextjs-refapp/refapp.log

- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
if: always()
with:
name: conformance-results-nextjs
path: seamless-cli/verify/harness/results/
if-no-files-found: ignore
8 changes: 8 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,16 @@ packages/
express/ Express adapter built on top of core
fastify/ Fastify adapter built on top of core
nextjs/ Next.js route handler adapter built on top of core
conformance/
nextjs-refapp/ reference app the adapter conformance specs drive
```

- `conformance/nextjs-refapp` is a private workspace package, never
published. The `nextjs` job in `.github/workflows/conformance.yml` runs the
seamless-cli adapter specs against it with `seamless verify --adapter-url`,
since the seamless-cli matrix builds only the Express and Fastify apps. Its
routes follow `verify/CONFORMANCE.md` in seamless-cli.

- Keep framework specifics in the adapter package, not in `core`.
- The Next.js adapter has no framework underneath, so it builds its own
request context (`src/internal/context.ts`): cookie and JSON body parsing
Expand Down
2 changes: 2 additions & 0 deletions conformance/nextjs-refapp/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
.next/
next-env.d.ts
10 changes: 10 additions & 0 deletions conformance/nextjs-refapp/app/%5F_captured/[recipient]/route.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { captured } from "../../../lib/seamless.mjs";

// A test hook, never part of a real deployment. The folder name is URL-encoded
// because App Router treats a folder starting with an underscore as private.
export const dynamic = "force-dynamic";

export async function GET(_request, { params }) {
const { recipient } = await params;
return Response.json(captured.get(recipient) ?? null);
}
6 changes: 6 additions & 0 deletions conformance/nextjs-refapp/app/api/me/route.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { guarded } from "../../../lib/seamless.mjs";

// The conformance contract's protected endpoint, behind the adapter's own guard.
export const GET = guarded((_request, _context, user) =>
Response.json({ id: user.id }),
);
9 changes: 9 additions & 0 deletions conformance/nextjs-refapp/app/auth/[...seamless]/route.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { authHandlers } from "../../../lib/seamless.mjs";

const handle = (request) => authHandlers()[request.method](request);

export const GET = handle;
export const POST = handle;
export const PUT = handle;
export const PATCH = handle;
export const DELETE = handle;
5 changes: 5 additions & 0 deletions conformance/nextjs-refapp/app/route.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export const dynamic = "force-dynamic";

export function GET() {
return Response.json({ ok: true });
}
58 changes: 58 additions & 0 deletions conformance/nextjs-refapp/lib/seamless.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// Configuration follows verify/CONFORMANCE.md in fells-code/seamless-cli. Read
// on the first request, not at import: `next build` loads every route module,
// and the adapter refuses to start without its secrets.
import { createSeamlessAuthHandler, requireAuth } from "@seamless-auth/nextjs";

const store = (globalThis.__seamlessRefapp ??= { captured: new Map() });

export const captured = store.captured;

const ok = (channel) => ({ accepted: true, provider: "capture", channel });

const handlers = {
async sendOtpEmail({ to, token }) {
captured.set(to, { token: String(token) });
return ok("email");
},
async sendOtpSms({ to, token }) {
captured.set(to, { token: String(token) });
return ok("sms");
},
async sendMagicLinkEmail({ to, token, magicLinkUrl }) {
captured.set(to, { token, magicLinkUrl });
return ok("email");
},
async sendBootstrapInviteEmail({ to, token, inviteUrl }) {
captured.set(to, { token, inviteUrl });
return ok("email");
},
};

function options() {
const issuer = process.env.AUTH_SERVER_ISSUER ?? process.env.AUTH_SERVER_URL;

return {
authServerUrl: process.env.AUTH_SERVER_URL,
authServerIssuer: issuer,
audience: issuer,
cookieSecret: process.env.COOKIE_SIGNING_KEY,
serviceSecret: process.env.SEAMLESS_SERVICE_TOKEN,
jwksKid: process.env.JWKS_KID,
// The harness is the only peer and sends each virtual user with its own
// X-Forwarded-For, so loopback is the one trusted hop.
trustedProxies: ["127.0.0.1", "::1"],
messaging: { handlers, defaults: { appName: "Seamless Verify" } },
};
}

export function authHandlers() {
return (store.handlers ??= createSeamlessAuthHandler(options()));
}

export function guarded(handler) {
let route;
return (request, context) => {
route ??= requireAuth(handler, options());
return route(request, context);
};
}
5 changes: 5 additions & 0 deletions conformance/nextjs-refapp/next.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// The adapter packages run as plain Node modules rather than through the
// bundler, as they would from node_modules in an adopter's app.
export default {
serverExternalPackages: ["@seamless-auth/nextjs", "@seamless-auth/core"],
};
17 changes: 17 additions & 0 deletions conformance/nextjs-refapp/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"name": "seamless-nextjs-refapp",
"version": "0.0.0",
"private": true,
"type": "module",
"description": "Reference app for the adapter conformance suite (seamless verify --adapter-url), built on @seamless-auth/nextjs. A test fixture, not an example deployment.",
"scripts": {
"build:refapp": "next build",
"start:refapp": "next start"
},
"dependencies": {
"@seamless-auth/nextjs": "workspace:*",
"next": "^16.4.0",
"react": "^19.2.0",
"react-dom": "^19.2.0"
}
}
17 changes: 16 additions & 1 deletion packages/core/src/guards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,8 +153,21 @@ function isRecoveryToken(token: unknown): boolean {
}
}

/**
* A session as a guard hands it on. `organizationId` is the verified access
* token's `org_id`, so it is only set on a path that checked that token against
* the auth API's key set (bearer, or a cookie with bearer verification on).
*/
export type GuardUser = SeamlessAuthUser & { organizationId?: string };

function organizationOf(claims: { org_id?: unknown }) {
return typeof claims.org_id === "string" && claims.org_id
? { organizationId: claims.org_id }
: {};
}

export type CookieAuthResult =
| { user: SeamlessAuthUser; rejection?: undefined }
| { user: GuardUser; rejection?: undefined }
| { user?: undefined; rejection: GuardRejection };

/**
Expand Down Expand Up @@ -293,6 +306,7 @@ export async function authenticateBearer(
iat: claims.iat,
exp: claims.exp,
token,
...organizationOf(claims),
},
};
}
Expand Down Expand Up @@ -375,6 +389,7 @@ async function verifyCookieAccessToken(
user: {
...user,
roles: Array.isArray(claims.roles) ? claims.roles : user.roles,
...organizationOf(claims),
},
};
}
Expand Down
41 changes: 41 additions & 0 deletions packages/core/tests/authenticateBearer.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,47 @@ describe("authenticateRequest", () => {
expect(result.rejection.warn).toMatch(/bearer/i);
});

it("carries the verified org_id as organizationId on both credentials", async () => {
const server = nextServer();
mockJwks(server);
const token = await accessToken(server, { org_id: "org-7" });
const bearer = { authServerUrl: server, audience: server };

const viaBearer = await authenticateRequest({
token: undefined,
cookieSecret: COOKIE_SECRET,
authorization: `Bearer ${token}`,
bearer,
});
const viaCookie = await authenticateRequest({
token: cookie({ sub: "user-123", org_id: "forged", token }),
cookieSecret: COOKIE_SECRET,
bearer,
});

expect(viaBearer.user.organizationId).toBe("org-7");
expect(viaCookie.user.organizationId).toBe("org-7");
});

it("leaves organizationId unset without a verified org_id", async () => {
const server = nextServer();
mockJwks(server);

const verified = await authenticateRequest({
token: undefined,
cookieSecret: COOKIE_SECRET,
authorization: `Bearer ${await accessToken(server)}`,
bearer: { authServerUrl: server, audience: server },
});
const unverified = await authenticateRequest({
token: cookie({ sub: "user-1", org_id: "org-7", token: INNER }),
cookieSecret: COOKIE_SECRET,
});

expect(verified.user).not.toHaveProperty("organizationId");
expect(unverified.user).not.toHaveProperty("organizationId");
});

it("still enforces cookie secret strength on the bearer path", async () => {
const server = nextServer();

Expand Down
Loading
Loading