@atlasauth/nextjs 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2 @@
1
+ export * from './matcher';
2
+ export * from './middleware';
package/dist/index.js ADDED
@@ -0,0 +1,19 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ __exportStar(require("./matcher"), exports);
18
+ __exportStar(require("./middleware"), exports);
19
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;AAAA,4CAA0B;AAC1B,+CAA6B"}
@@ -0,0 +1,67 @@
1
+ /**
2
+ * §10.1 `@atlasauth/nextjs`: "route protection matcher config".
3
+ *
4
+ * The single most consequential decision in this package is the DEFAULT. A
5
+ * matcher that treats unlisted routes as public means every new page a
6
+ * developer adds ships unprotected, and nothing anywhere reports it — the page
7
+ * simply works, for everyone, including people who are not signed in. So the
8
+ * default is deny: a route is protected unless it matches a public pattern.
9
+ *
10
+ * That is the opposite of comfortable. It means a developer who forgets to list
11
+ * their marketing page gets redirected to sign-in and notices immediately,
12
+ * which is a loud failure rather than a silent breach.
13
+ */
14
+ /**
15
+ * Patterns support one wildcard form: `(.*)` at the end, matching Next.js's own
16
+ * matcher syntax so a developer writes one dialect rather than two.
17
+ *
18
+ * Deliberately NOT a general regex. A customer-authored regex in an
19
+ * authorization path is a catastrophic-backtracking outage waiting to happen,
20
+ * and `/admin(.*)` covers what anyone actually needs.
21
+ */
22
+ export declare function matchesPattern(pathname: string, pattern: string): boolean;
23
+ export declare function matchesAny(pathname: string, patterns: readonly string[]): boolean;
24
+ export declare function isStaticAsset(pathname: string): boolean;
25
+ export interface MiddlewareConfig {
26
+ /** Routes reachable without a session. Everything else requires one. */
27
+ publicRoutes?: readonly string[];
28
+ /** Routes that require a session even if a public pattern would match them. */
29
+ protectedRoutes?: readonly string[];
30
+ signInUrl?: string;
31
+ /** Exact origins a `redirect_url` may point at (§13.1). */
32
+ allowedOrigins?: readonly string[];
33
+ }
34
+ export declare const DEFAULT_SIGN_IN_URL = "/sign-in";
35
+ export type RouteDecision = {
36
+ action: 'skip';
37
+ } | {
38
+ action: 'allow';
39
+ } | {
40
+ action: 'redirect';
41
+ to: string;
42
+ } | {
43
+ action: 'unauthorized';
44
+ };
45
+ /**
46
+ * Decide what to do with a request.
47
+ *
48
+ * `protectedRoutes` wins over `publicRoutes` deliberately. A developer who
49
+ * writes `publicRoutes: ['/(.*)']` while iterating and then protects `/admin`
50
+ * expects the narrower, more explicit rule to hold — and the failure of the
51
+ * other precedence is that `/admin` is public.
52
+ */
53
+ export declare function decideRoute(input: {
54
+ pathname: string;
55
+ signedIn: boolean;
56
+ config?: MiddlewareConfig;
57
+ /** For API routes, a redirect is useless — the caller wants a 401. */
58
+ isApiRoute?: boolean;
59
+ }): RouteDecision;
60
+ /**
61
+ * §13.1 open redirect: validate a `redirect_url` before sending anyone to it.
62
+ *
63
+ * A relative path is allowed because it cannot leave the site. An absolute URL
64
+ * must match an allowed origin EXACTLY — prefix matching is how
65
+ * `https://app.example.com.evil.test` gets accepted.
66
+ */
67
+ export declare function safeRedirect(redirectUrl: string | null | undefined, allowedOrigins?: readonly string[], fallback?: string): string;
@@ -0,0 +1,111 @@
1
+ "use strict";
2
+ /**
3
+ * §10.1 `@atlasauth/nextjs`: "route protection matcher config".
4
+ *
5
+ * The single most consequential decision in this package is the DEFAULT. A
6
+ * matcher that treats unlisted routes as public means every new page a
7
+ * developer adds ships unprotected, and nothing anywhere reports it — the page
8
+ * simply works, for everyone, including people who are not signed in. So the
9
+ * default is deny: a route is protected unless it matches a public pattern.
10
+ *
11
+ * That is the opposite of comfortable. It means a developer who forgets to list
12
+ * their marketing page gets redirected to sign-in and notices immediately,
13
+ * which is a loud failure rather than a silent breach.
14
+ */
15
+ Object.defineProperty(exports, "__esModule", { value: true });
16
+ exports.DEFAULT_SIGN_IN_URL = void 0;
17
+ exports.matchesPattern = matchesPattern;
18
+ exports.matchesAny = matchesAny;
19
+ exports.isStaticAsset = isStaticAsset;
20
+ exports.decideRoute = decideRoute;
21
+ exports.safeRedirect = safeRedirect;
22
+ /**
23
+ * Patterns support one wildcard form: `(.*)` at the end, matching Next.js's own
24
+ * matcher syntax so a developer writes one dialect rather than two.
25
+ *
26
+ * Deliberately NOT a general regex. A customer-authored regex in an
27
+ * authorization path is a catastrophic-backtracking outage waiting to happen,
28
+ * and `/admin(.*)` covers what anyone actually needs.
29
+ */
30
+ function matchesPattern(pathname, pattern) {
31
+ if (pattern === pathname)
32
+ return true;
33
+ const wildcard = pattern.indexOf('(.*)');
34
+ if (wildcard === -1)
35
+ return false;
36
+ // Anything after the wildcard is meaningless — `(.*)` already matched it.
37
+ const prefix = pattern.slice(0, wildcard);
38
+ return pathname.startsWith(prefix);
39
+ }
40
+ function matchesAny(pathname, patterns) {
41
+ return patterns.some((pattern) => matchesPattern(pathname, pattern));
42
+ }
43
+ /**
44
+ * Paths that are never auth decisions.
45
+ *
46
+ * Running auth on every image request is a measurable cost on a page with forty
47
+ * assets, and a redirect on a `.css` file breaks the page rather than
48
+ * protecting it.
49
+ */
50
+ const ALWAYS_SKIPPED = ['/_next/', '/favicon.ico', '/robots.txt', '/sitemap.xml', '/.well-known/'];
51
+ function isStaticAsset(pathname) {
52
+ if (ALWAYS_SKIPPED.some((prefix) => pathname.startsWith(prefix)))
53
+ return true;
54
+ // A dotted final segment is a file, not a page.
55
+ const last = pathname.split('/').pop() ?? '';
56
+ return last.includes('.');
57
+ }
58
+ exports.DEFAULT_SIGN_IN_URL = '/sign-in';
59
+ /**
60
+ * Decide what to do with a request.
61
+ *
62
+ * `protectedRoutes` wins over `publicRoutes` deliberately. A developer who
63
+ * writes `publicRoutes: ['/(.*)']` while iterating and then protects `/admin`
64
+ * expects the narrower, more explicit rule to hold — and the failure of the
65
+ * other precedence is that `/admin` is public.
66
+ */
67
+ function decideRoute(input) {
68
+ const config = input.config ?? {};
69
+ if (isStaticAsset(input.pathname))
70
+ return { action: 'skip' };
71
+ const explicitlyProtected = matchesAny(input.pathname, config.protectedRoutes ?? []);
72
+ const isPublic = !explicitlyProtected && matchesAny(input.pathname, config.publicRoutes ?? []);
73
+ if (isPublic || input.signedIn)
74
+ return { action: 'allow' };
75
+ /**
76
+ * An API route gets a 401, not a redirect. Redirecting a fetch to an HTML
77
+ * sign-in page produces a JSON parse error in the caller's code, three
78
+ * frames from the actual problem.
79
+ */
80
+ if (input.isApiRoute)
81
+ return { action: 'unauthorized' };
82
+ const signInUrl = config.signInUrl ?? exports.DEFAULT_SIGN_IN_URL;
83
+ return {
84
+ action: 'redirect',
85
+ to: `${signInUrl}?redirect_url=${encodeURIComponent(input.pathname)}`,
86
+ };
87
+ }
88
+ /**
89
+ * §13.1 open redirect: validate a `redirect_url` before sending anyone to it.
90
+ *
91
+ * A relative path is allowed because it cannot leave the site. An absolute URL
92
+ * must match an allowed origin EXACTLY — prefix matching is how
93
+ * `https://app.example.com.evil.test` gets accepted.
94
+ */
95
+ function safeRedirect(redirectUrl, allowedOrigins = [], fallback = '/') {
96
+ if (!redirectUrl)
97
+ return fallback;
98
+ // Protocol-relative `//evil.test` is an absolute URL wearing a disguise.
99
+ if (redirectUrl.startsWith('//'))
100
+ return fallback;
101
+ if (redirectUrl.startsWith('/'))
102
+ return redirectUrl;
103
+ try {
104
+ const url = new URL(redirectUrl);
105
+ return allowedOrigins.includes(url.origin) ? redirectUrl : fallback;
106
+ }
107
+ catch {
108
+ return fallback;
109
+ }
110
+ }
111
+ //# sourceMappingURL=matcher.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"matcher.js","sourceRoot":"","sources":["../src/matcher.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;GAYG;;;AAUH,wCASC;AAED,gCAEC;AAWD,sCAKC;AA4BD,kCA4BC;AASD,oCAiBC;AAvHD;;;;;;;GAOG;AACH,SAAgB,cAAc,CAAC,QAAgB,EAAE,OAAe;IAC9D,IAAI,OAAO,KAAK,QAAQ;QAAE,OAAO,IAAI,CAAC;IAEtC,MAAM,QAAQ,GAAG,OAAO,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC;IACzC,IAAI,QAAQ,KAAK,CAAC,CAAC;QAAE,OAAO,KAAK,CAAC;IAElC,0EAA0E;IAC1E,MAAM,MAAM,GAAG,OAAO,CAAC,KAAK,CAAC,CAAC,EAAE,QAAQ,CAAC,CAAC;IAC1C,OAAO,QAAQ,CAAC,UAAU,CAAC,MAAM,CAAC,CAAC;AACrC,CAAC;AAED,SAAgB,UAAU,CAAC,QAAgB,EAAE,QAA2B;IACtE,OAAO,QAAQ,CAAC,IAAI,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,cAAc,CAAC,QAAQ,EAAE,OAAO,CAAC,CAAC,CAAC;AACvE,CAAC;AAED;;;;;;GAMG;AACH,MAAM,cAAc,GAAG,CAAC,SAAS,EAAE,cAAc,EAAE,aAAa,EAAE,cAAc,EAAE,eAAe,CAAC,CAAC;AAEnG,SAAgB,aAAa,CAAC,QAAgB;IAC5C,IAAI,cAAc,CAAC,IAAI,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,QAAQ,CAAC,UAAU,CAAC,MAAM,CAAC,CAAC;QAAE,OAAO,IAAI,CAAC;IAC9E,gDAAgD;IAChD,MAAM,IAAI,GAAG,QAAQ,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,IAAI,EAAE,CAAC;IAC7C,OAAO,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC;AAC5B,CAAC;AAYY,QAAA,mBAAmB,GAAG,UAAU,CAAC;AAQ9C;;;;;;;GAOG;AACH,SAAgB,WAAW,CAAC,KAM3B;IACC,MAAM,MAAM,GAAG,KAAK,CAAC,MAAM,IAAI,EAAE,CAAC;IAElC,IAAI,aAAa,CAAC,KAAK,CAAC,QAAQ,CAAC;QAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,CAAC;IAE7D,MAAM,mBAAmB,GAAG,UAAU,CAAC,KAAK,CAAC,QAAQ,EAAE,MAAM,CAAC,eAAe,IAAI,EAAE,CAAC,CAAC;IACrF,MAAM,QAAQ,GAAG,CAAC,mBAAmB,IAAI,UAAU,CAAC,KAAK,CAAC,QAAQ,EAAE,MAAM,CAAC,YAAY,IAAI,EAAE,CAAC,CAAC;IAE/F,IAAI,QAAQ,IAAI,KAAK,CAAC,QAAQ;QAAE,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC;IAE3D;;;;OAIG;IACH,IAAI,KAAK,CAAC,UAAU;QAAE,OAAO,EAAE,MAAM,EAAE,cAAc,EAAE,CAAC;IAExD,MAAM,SAAS,GAAG,MAAM,CAAC,SAAS,IAAI,2BAAmB,CAAC;IAC1D,OAAO;QACL,MAAM,EAAE,UAAU;QAClB,EAAE,EAAE,GAAG,SAAS,iBAAiB,kBAAkB,CAAC,KAAK,CAAC,QAAQ,CAAC,EAAE;KACtE,CAAC;AACJ,CAAC;AAED;;;;;;GAMG;AACH,SAAgB,YAAY,CAC1B,WAAsC,EACtC,iBAAoC,EAAE,EACtC,QAAQ,GAAG,GAAG;IAEd,IAAI,CAAC,WAAW;QAAE,OAAO,QAAQ,CAAC;IAElC,yEAAyE;IACzE,IAAI,WAAW,CAAC,UAAU,CAAC,IAAI,CAAC;QAAE,OAAO,QAAQ,CAAC;IAClD,IAAI,WAAW,CAAC,UAAU,CAAC,GAAG,CAAC;QAAE,OAAO,WAAW,CAAC;IAEpD,IAAI,CAAC;QACH,MAAM,GAAG,GAAG,IAAI,GAAG,CAAC,WAAW,CAAC,CAAC;QACjC,OAAO,cAAc,CAAC,QAAQ,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,QAAQ,CAAC;IACtE,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,QAAQ,CAAC;IAClB,CAAC;AACH,CAAC"}
@@ -0,0 +1,103 @@
1
+ import { AtlasBackend, type ProtectCondition, type SessionClaims } from '@atlasauth/backend';
2
+ import { safeRedirect, type MiddlewareConfig } from './matcher';
3
+ /**
4
+ * §10.1: "Middleware: reads cookies, verifies JWT at the edge, exposes auth()
5
+ * in server components/route handlers, route protection matcher config."
6
+ *
7
+ * Verifying at the EDGE is the point. A middleware that called Atlas on every
8
+ * request would add a round trip to every page load in the customer's app and
9
+ * make Atlas's latency theirs — which is precisely what §7.3's local
10
+ * verification exists to avoid. The JWKS is cached in-process by
11
+ * `@atlasauth/backend`, so a warm edge instance verifies with no network at all.
12
+ */
13
+ export interface AtlasMiddlewareOptions extends MiddlewareConfig {
14
+ publishableKey?: string;
15
+ jwksUrl: string;
16
+ issuer: string;
17
+ authorizedParties?: readonly string[];
18
+ fetchImpl?: typeof fetch;
19
+ /**
20
+ * Cross-property SSO handshake. When a protected route has no valid local
21
+ * session, bounce the browser ONCE through the Atlas handshake endpoint
22
+ * (`GET {fapiOrigin}/v1/client/handshake`) before falling back to the sign-in
23
+ * redirect. The endpoint reads the authoritative session and returns a fresh
24
+ * first-party one, so a user already signed in on another of the customer's
25
+ * properties is admitted here with no login screen. Requires `publishableKey`.
26
+ * The loop is guarded by the `__atlas_hs` marker the endpoint appends: once it
27
+ * comes back (ok → session cookie now present; signed_out → nobody's signed
28
+ * in), we don't handshake again and let normal routing proceed.
29
+ */
30
+ handshake?: boolean;
31
+ /** Origin of the Atlas Frontend API (where `/v1/client/handshake` lives).
32
+ * Defaults to the origin of `jwksUrl`. */
33
+ fapiOrigin?: string;
34
+ }
35
+ /** Minimal shapes, so this package does not depend on Next.js at build time. */
36
+ export interface EdgeRequest {
37
+ nextUrl: {
38
+ pathname: string;
39
+ search?: string;
40
+ };
41
+ headers: {
42
+ get(name: string): string | null;
43
+ };
44
+ url?: string;
45
+ }
46
+ /** The subset of a NextResponse cookie jar the handshake redeem needs. */
47
+ export interface EdgeCookies {
48
+ set(name: string, value: string, options?: Record<string, unknown>): void;
49
+ }
50
+ /** A redirect response we can also set first-party cookies on (NextResponse). */
51
+ export interface EdgeRedirect {
52
+ cookies?: EdgeCookies;
53
+ }
54
+ export interface EdgeResponse {
55
+ next(): unknown;
56
+ redirect(url: string | URL): EdgeRedirect;
57
+ json(body: unknown, init?: {
58
+ status?: number;
59
+ }): unknown;
60
+ }
61
+ export interface AuthState {
62
+ userId: string | null;
63
+ sessionId: string | null;
64
+ orgId: string | null;
65
+ orgRole: string | null;
66
+ claims: SessionClaims | null;
67
+ }
68
+ export declare const SIGNED_OUT: AuthState;
69
+ /**
70
+ * Resolve the auth state for a request.
71
+ *
72
+ * A token that fails verification produces SIGNED_OUT, never a thrown error. A
73
+ * middleware that throws takes the whole page down; treating an unverifiable
74
+ * token as "not signed in" degrades to the sign-in screen, which is what the
75
+ * user needs anyway.
76
+ */
77
+ export declare function resolveAuth(backend: AtlasBackend, request: EdgeRequest): Promise<AuthState>;
78
+ export declare function atlasMiddleware(options: AtlasMiddlewareOptions): (request: EdgeRequest, response: EdgeResponse) => Promise<unknown>;
79
+ /**
80
+ * The `auth()` helper for server components and route handlers.
81
+ *
82
+ * Returns a plain state rather than throwing on a signed-out user, because a
83
+ * server component that renders differently for anonymous visitors is the
84
+ * normal case, not an error.
85
+ */
86
+ export declare function createAuthHelper(options: AtlasMiddlewareOptions): (request: EdgeRequest) => Promise<AuthState & {
87
+ /**
88
+ * Mirrors `<Protect>`, for gating inside a server component. Full
89
+ * condition set (permission / role / anyPermission / allPermissions) via
90
+ * the shared `@atlasauth/authz` primitive — the same one React evaluates, so a
91
+ * check written for a component behaves identically here.
92
+ */
93
+ has(condition?: ProtectCondition): boolean;
94
+ /**
95
+ * Assert access, throwing `ForbiddenError` when unmet. With no argument it
96
+ * asserts signed-in (anonymous access is a bug); with a condition it also
97
+ * asserts the permission/role, like Clerk's `auth().protect({ permission })`.
98
+ */
99
+ protect(condition?: ProtectCondition): asserts this is AuthState & {
100
+ userId: string;
101
+ };
102
+ }>;
103
+ export { safeRedirect };
@@ -0,0 +1,207 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.safeRedirect = exports.SIGNED_OUT = void 0;
4
+ exports.resolveAuth = resolveAuth;
5
+ exports.atlasMiddleware = atlasMiddleware;
6
+ exports.createAuthHelper = createAuthHelper;
7
+ const backend_1 = require("@atlasauth/backend");
8
+ const matcher_1 = require("./matcher");
9
+ Object.defineProperty(exports, "safeRedirect", { enumerable: true, get: function () { return matcher_1.safeRedirect; } });
10
+ exports.SIGNED_OUT = {
11
+ userId: null,
12
+ sessionId: null,
13
+ orgId: null,
14
+ orgRole: null,
15
+ claims: null,
16
+ };
17
+ function readCookie(header, name) {
18
+ if (!header)
19
+ return undefined;
20
+ for (const part of header.split(';')) {
21
+ const [key, ...rest] = part.trim().split('=');
22
+ if (key === name)
23
+ return rest.join('=');
24
+ }
25
+ return undefined;
26
+ }
27
+ /**
28
+ * Resolve the auth state for a request.
29
+ *
30
+ * A token that fails verification produces SIGNED_OUT, never a thrown error. A
31
+ * middleware that throws takes the whole page down; treating an unverifiable
32
+ * token as "not signed in" degrades to the sign-in screen, which is what the
33
+ * user needs anyway.
34
+ */
35
+ async function resolveAuth(backend, request) {
36
+ const header = request.headers.get('authorization');
37
+ const bearer = header?.startsWith('Bearer ') ? header.slice(7) : undefined;
38
+ const cookie = readCookie(request.headers.get('cookie'), '__session');
39
+ const token = bearer ?? cookie;
40
+ if (!token)
41
+ return exports.SIGNED_OUT;
42
+ const result = await backend.verify(token);
43
+ if (!result.ok)
44
+ return exports.SIGNED_OUT;
45
+ return {
46
+ userId: result.claims.sub,
47
+ sessionId: result.claims.sid,
48
+ orgId: result.claims.org_id ?? null,
49
+ orgRole: result.claims.org_role ?? null,
50
+ claims: result.claims,
51
+ };
52
+ }
53
+ function atlasMiddleware(options) {
54
+ const backend = new backend_1.AtlasBackend({
55
+ jwksUrl: options.jwksUrl,
56
+ issuer: options.issuer,
57
+ authorizedParties: options.authorizedParties,
58
+ fetchImpl: options.fetchImpl,
59
+ });
60
+ return async function middleware(request, response) {
61
+ /**
62
+ * Cross-domain satellite RETURN: the handshake endpoint bounced the browser
63
+ * back with `?__atlas_hs=ok&__atlas_hu=<userId>&__atlas_hn=<nonce>`. Exchange
64
+ * the single-use nonce (server-to-server) for a fresh session and set it as
65
+ * this property's OWN first-party cookies, then re-enter on a clean URL so
66
+ * the normal verify path picks up the new `__session`. This is the piece
67
+ * that makes a truly separate-domain property seamless (a same-registrable-
68
+ * domain subdomain instead gets a parent-domain cookie straight from the
69
+ * endpoint and never carries a nonce). Runs before anything else — the
70
+ * session cookie isn't set yet, so route/auth decisions must wait for it.
71
+ */
72
+ if (options.handshake && options.publishableKey && request.url) {
73
+ const url = new URL(request.url);
74
+ const hs = url.searchParams.get('__atlas_hs');
75
+ const userId = url.searchParams.get('__atlas_hu');
76
+ const nonce = url.searchParams.get('__atlas_hn');
77
+ if (hs === 'ok' && userId && nonce) {
78
+ // PKCE proof: redeem only with the verifier we stashed HttpOnly on the
79
+ // way out (its challenge was bound to this nonce at mint time). Without
80
+ // it a leaked nonce is inert — so a missing verifier can never redeem.
81
+ const codeVerifier = readCookie(request.headers.get('cookie'), '__atlas_hv');
82
+ const fapiOrigin = options.fapiOrigin ?? new URL(options.jwksUrl).origin;
83
+ const session = codeVerifier
84
+ ? await (0, backend_1.redeemHandshake)({
85
+ fapiOrigin,
86
+ publishableKey: options.publishableKey,
87
+ userId,
88
+ nonce,
89
+ codeVerifier,
90
+ fetchImpl: options.fetchImpl,
91
+ })
92
+ : null;
93
+ const clean = new URL(url.toString());
94
+ for (const k of ['__atlas_hs', '__atlas_hu', '__atlas_hn'])
95
+ clean.searchParams.delete(k);
96
+ if (session) {
97
+ const res = response.redirect(clean.toString());
98
+ res.cookies?.set('__session', session.jwt, { path: '/', sameSite: 'lax', secure: true });
99
+ res.cookies?.set('__atlas_rt', session.refreshToken, {
100
+ path: '/',
101
+ httpOnly: true,
102
+ sameSite: 'lax',
103
+ secure: true,
104
+ });
105
+ // The verifier is spent — clear it so it can't be reused or leak.
106
+ res.cookies?.set('__atlas_hv', '', { path: '/', maxAge: 0 });
107
+ return res;
108
+ }
109
+ // A bad/expired/replayed nonce, a rejected PKCE proof, or no verifier →
110
+ // no session. Carry a SPENT marker (__atlas_hs=failed) instead of a clean
111
+ // URL: the outbound guard keys off the presence of __atlas_hs, so a clean
112
+ // URL here would bounce straight back to the handshake forever. With the
113
+ // marker, normal routing takes over and sends the user to sign-in.
114
+ clean.searchParams.set('__atlas_hs', 'failed');
115
+ const res = response.redirect(clean.toString());
116
+ res.cookies?.set('__atlas_hv', '', { path: '/', maxAge: 0 });
117
+ return res;
118
+ }
119
+ }
120
+ const auth = await resolveAuth(backend, request);
121
+ const decision = (0, matcher_1.decideRoute)({
122
+ pathname: request.nextUrl.pathname,
123
+ signedIn: auth.userId !== null,
124
+ config: options,
125
+ isApiRoute: request.nextUrl.pathname.startsWith('/api/'),
126
+ });
127
+ switch (decision.action) {
128
+ case 'skip':
129
+ case 'allow':
130
+ return response.next();
131
+ case 'unauthorized':
132
+ return response.json({ errors: [{ code: 'UNAUTHENTICATED' }] }, { status: 401 });
133
+ case 'redirect': {
134
+ /**
135
+ * Cross-property handshake: before sending a signed-out user to the
136
+ * sign-in page, try the Atlas handshake ONCE — they may already have a
137
+ * session on a sibling property. The `__atlas_hs` marker the endpoint
138
+ * appends on its way back is our loop guard: if it's absent we bounce to
139
+ * the handshake; if it's present the handshake already ran (and either
140
+ * set a session cookie we'll now verify on re-entry, or reported
141
+ * signed_out), so we fall through to the normal sign-in redirect.
142
+ */
143
+ if (options.handshake && options.publishableKey && request.url) {
144
+ const current = new URL(request.url);
145
+ if (!current.searchParams.has('__atlas_hs')) {
146
+ // Generate a PKCE pair: only the challenge rides the (loggable)
147
+ // handshake URL; the verifier is stashed HttpOnly and proves
148
+ // possession at redeem, so a captured return-URL nonce is inert.
149
+ const { verifier, challenge } = await (0, backend_1.createPkcePair)();
150
+ const fapiOrigin = options.fapiOrigin ?? new URL(options.jwksUrl).origin;
151
+ const hs = new URL('/v1/client/handshake', fapiOrigin);
152
+ hs.searchParams.set('publishable_key', options.publishableKey);
153
+ hs.searchParams.set('redirect_url', current.toString());
154
+ hs.searchParams.set('code_challenge', challenge);
155
+ const res = response.redirect(hs.toString());
156
+ res.cookies?.set('__atlas_hv', verifier, {
157
+ path: '/',
158
+ httpOnly: true,
159
+ sameSite: 'lax',
160
+ secure: true,
161
+ maxAge: 300, // the handshake round trip is seconds; don't linger
162
+ });
163
+ return res;
164
+ }
165
+ }
166
+ /**
167
+ * The redirect target is built from the request's own pathname, not
168
+ * from anything a caller supplied — but it still goes through
169
+ * `safeRedirect`, because a `redirect_url` already present in the query
170
+ * would otherwise be carried forward unchecked (§13.1).
171
+ */
172
+ const base = request.url ? new URL(request.url).origin : '';
173
+ return response.redirect(`${base}${decision.to}`);
174
+ }
175
+ }
176
+ };
177
+ }
178
+ /**
179
+ * The `auth()` helper for server components and route handlers.
180
+ *
181
+ * Returns a plain state rather than throwing on a signed-out user, because a
182
+ * server component that renders differently for anonymous visitors is the
183
+ * normal case, not an error.
184
+ */
185
+ function createAuthHelper(options) {
186
+ const backend = new backend_1.AtlasBackend({
187
+ jwksUrl: options.jwksUrl,
188
+ issuer: options.issuer,
189
+ authorizedParties: options.authorizedParties,
190
+ fetchImpl: options.fetchImpl,
191
+ });
192
+ return async function auth(request) {
193
+ const state = await resolveAuth(backend, request);
194
+ return {
195
+ ...state,
196
+ has(condition = {}) {
197
+ return (0, backend_1.hasFromClaims)(state.claims, condition);
198
+ },
199
+ protect(condition = {}) {
200
+ const outcome = (0, backend_1.evaluate)(state.claims, condition);
201
+ if (!outcome.allowed)
202
+ throw new backend_1.ForbiddenError(outcome.reason, condition);
203
+ },
204
+ };
205
+ };
206
+ }
207
+ //# sourceMappingURL=middleware.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"middleware.js","sourceRoot":"","sources":["../src/middleware.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "@atlasauth/nextjs",
3
+ "version": "0.1.0",
4
+ "main": "./dist/index.js",
5
+ "types": "./dist/index.d.ts",
6
+ "dependencies": {
7
+ "@atlasauth/backend": "0.1.0"
8
+ },
9
+ "devDependencies": {
10
+ "typescript": "^5.6.3",
11
+ "vitest": "^2.1.4",
12
+ "jose": "^5.9.6"
13
+ },
14
+ "publishConfig": {
15
+ "access": "public"
16
+ },
17
+ "files": [
18
+ "dist",
19
+ "README.md"
20
+ ],
21
+ "scripts": {
22
+ "build": "tsc -p tsconfig.build.json",
23
+ "typecheck": "tsc -p tsconfig.json --noEmit",
24
+ "test": "vitest run"
25
+ }
26
+ }