@passflow/node 0.0.0-stage → 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.
- package/LICENSE +21 -0
- package/README.md +179 -2
- package/dist/claims-BHJhnzch.d.cts +51 -0
- package/dist/claims-BHJhnzch.d.mts +51 -0
- package/dist/evaluate-BTuol-Oa.mjs +348 -0
- package/dist/evaluate-C20MecTf.cjs +491 -0
- package/dist/express.cjs +52 -0
- package/dist/express.d.cts +29 -0
- package/dist/express.d.mts +29 -0
- package/dist/express.mjs +49 -0
- package/dist/fastify.cjs +67 -0
- package/dist/fastify.d.cts +40 -0
- package/dist/fastify.d.mts +40 -0
- package/dist/fastify.mjs +63 -0
- package/dist/hono.cjs +51 -0
- package/dist/hono.d.cts +24 -0
- package/dist/hono.d.mts +24 -0
- package/dist/hono.mjs +48 -0
- package/dist/index.cjs +558 -0
- package/dist/index.d.cts +141 -0
- package/dist/index.d.mts +141 -0
- package/dist/index.mjs +539 -0
- package/package.json +103 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 MadAppGang Pty Ltd
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,180 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @passflow/node
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Protect your own API with Passflow tokens. One import and one middleware call give you token verification, an authentication-level floor, a freshness requirement, and the exact RFC 9470 challenge the Passflow browser SDK needs to run a step-up.
|
|
4
|
+
|
|
5
|
+
Requires Node.js 22.12 or newer, and Express, Fastify and Hono are optional peers, so you install only the framework you use.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @passflow/node
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```js
|
|
12
|
+
import { AAL2, createVerifier } from '@passflow/node';
|
|
13
|
+
import { getAuth, passflowExpress } from '@passflow/node/express';
|
|
14
|
+
import express from 'express';
|
|
15
|
+
|
|
16
|
+
const verifier = createVerifier({ issuer: 'https://auth.example.com', audience: '7f3a9c21d4b8e6' });
|
|
17
|
+
const passflow = passflowExpress(verifier);
|
|
18
|
+
|
|
19
|
+
const app = express();
|
|
20
|
+
app.get('/invoices', passflow.requireACR(AAL2), (req, res) => res.json({ sub: getAuth(req).sub }));
|
|
21
|
+
app.post('/transfers', passflow.requireFresh(300), (_req, res) => res.json({ ok: true }));
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
`audience` is the ID of a Passflow application, or a list of them. The Passflow documentation has step-by-step tutorials for Express, Fastify and Hono (`docs/tutorials/node-backend.md`) and a runnable API in `examples/resource-server`.
|
|
25
|
+
|
|
26
|
+
## Use it with a framework
|
|
27
|
+
|
|
28
|
+
### Express
|
|
29
|
+
|
|
30
|
+
`passflowExpress(verifier)` returns `authenticate`, `requireACR(min)` and `requireFresh(seconds)`, and the last two work alone without `authenticate` in front. Claims live on `req.passflow`, which `getAuth(req)` reads, and that name cannot collide with the global `auth` type that `express-jwt` declares.
|
|
31
|
+
|
|
32
|
+
```js
|
|
33
|
+
app.get('/me', passflow.authenticate, (req, res) => res.json({ sub: getAuth(req).sub }));
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### Fastify
|
|
37
|
+
|
|
38
|
+
Register the plugin once per instance, because it only decorates: routes opt in one at a time and public routes are untouched.
|
|
39
|
+
|
|
40
|
+
```js
|
|
41
|
+
import { AAL2, createVerifier } from '@passflow/node';
|
|
42
|
+
import { getAuth, passflowFastify } from '@passflow/node/fastify';
|
|
43
|
+
import Fastify from 'fastify';
|
|
44
|
+
|
|
45
|
+
const verifier = createVerifier({ issuer: 'https://auth.example.com', audience: '7f3a9c21d4b8e6' });
|
|
46
|
+
|
|
47
|
+
const app = Fastify();
|
|
48
|
+
await app.register(passflowFastify, { verifier });
|
|
49
|
+
|
|
50
|
+
app.get('/invoices', { preHandler: app.passflow.requireACR(AAL2) }, async (request) => ({
|
|
51
|
+
subject: getAuth(request).sub,
|
|
52
|
+
}));
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
A second registration throws Fastify's own `FST_ERR_DEC_ALREADY_PRESENT`, so use `createFastifyHooks(verifier)` for every further verifier. It returns the same three hooks with no plugin and no decoration.
|
|
56
|
+
|
|
57
|
+
### Hono
|
|
58
|
+
|
|
59
|
+
`@passflow/node` and `@passflow/node/hono` import no Node built-ins, so they run on any runtime with WebCrypto and `fetch`.
|
|
60
|
+
|
|
61
|
+
```js
|
|
62
|
+
import { AAL2, createVerifier } from '@passflow/node';
|
|
63
|
+
import { getAuth, passflowHono } from '@passflow/node/hono';
|
|
64
|
+
import { Hono } from 'hono';
|
|
65
|
+
|
|
66
|
+
const passflow = passflowHono(createVerifier({ issuer: 'https://auth.example.com', audience: '7f3a9c21d4b8e6' }));
|
|
67
|
+
const app = new Hono();
|
|
68
|
+
|
|
69
|
+
app.get('/invoices', passflow.requireACR(AAL2), (c) => c.json({ subject: getAuth(c).sub }));
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Responses and CORS
|
|
73
|
+
|
|
74
|
+
| Situation | Status | `WWW-Authenticate` |
|
|
75
|
+
|---|---|---|
|
|
76
|
+
| No token, or not a `Bearer` token | 401 | `Bearer` |
|
|
77
|
+
| Invalid or expired token | 401 | `Bearer error="invalid_token"` |
|
|
78
|
+
| Token below the required level | 401 | `Bearer error="insufficient_user_authentication", acr_values="2"` |
|
|
79
|
+
| Authentication too old | 401 | `Bearer error="insufficient_user_authentication", max_age=300` |
|
|
80
|
+
| Key set unavailable | 503 | none |
|
|
81
|
+
|
|
82
|
+
Every rejection has `Cache-Control: no-store` and an empty body, and the middleware writes it directly, so your own error handler cannot drop the challenge. The browser SDK acts only on the step-up challenge, and the library never sends one for an authentication failure. Nothing from the token, the request or the key set URL appears in any error or response, and the package writes no logs.
|
|
83
|
+
|
|
84
|
+
A browser hides `WWW-Authenticate` from a cross-origin page unless the response names it in `Access-Control-Expose-Headers`. The SDK then sees a bare 401 and cannot start the step-up, and a `*` entry does not cover the header on a credentialed request.
|
|
85
|
+
|
|
86
|
+
Each adapter exports `exposeChallengeHeader`, which adds the header to your CORS options and keeps the entries you already have, under the option name that framework's middleware uses: `exposedHeaders` for `cors` and `@fastify/cors`, `exposeHeaders` for `hono/cors`.
|
|
87
|
+
|
|
88
|
+
```js
|
|
89
|
+
import { exposeChallengeHeader } from '@passflow/node/express';
|
|
90
|
+
import cors from 'cors';
|
|
91
|
+
|
|
92
|
+
app.use(cors(exposeChallengeHeader({ origin: 'https://app.example.com', credentials: true })));
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
`CHALLENGE_HEADER` and `CORS_EXPOSED_HEADERS` are exported if you build the list yourself.
|
|
96
|
+
|
|
97
|
+
## Claims, configuration and composition
|
|
98
|
+
|
|
99
|
+
### Reading the claims
|
|
100
|
+
|
|
101
|
+
`getAuth(req)` throws `PassflowUsageError` when no Passflow middleware ran, which your framework turns into a 500.
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
type PassflowClaims = {
|
|
105
|
+
sub: string; iss: string; aud: string[]; exp: number; iat?: number;
|
|
106
|
+
authTime?: number; // whole Unix seconds; absent when the claim is absent, not a number or beyond ±2^53
|
|
107
|
+
acr: string; // '0' when the claim is absent or not a string
|
|
108
|
+
amr: string[]; scopes: string[]; sid?: string; type: string;
|
|
109
|
+
tenants: Record<string, TenantMembership>;
|
|
110
|
+
verifiedAt: number; // the verifier's clock at verification, in Unix seconds
|
|
111
|
+
raw: Record<string, unknown>;
|
|
112
|
+
};
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
`rolesOf(claims, tenantId)` returns the roles of the tenant's root group. Compare levels with `acrSatisfies(acr, min)`, which is exact over the whole 64-bit range and never satisfied by a required level that parses to 0. `acrLevel(acr)` returns a `number` for display only. It is exact up to 2^53, and the library never decides with it.
|
|
116
|
+
|
|
117
|
+
### Configuration
|
|
118
|
+
|
|
119
|
+
```js
|
|
120
|
+
createVerifier({
|
|
121
|
+
issuer: 'https://auth.example.com', // required, exact match
|
|
122
|
+
audience: ['7f3a9c21d4b8e6', 'b81e4d07a39c52'], // required: a string or a list; the token must name at least one
|
|
123
|
+
jwksUrl: 'https://auth.example.com/.well-known/jwks.json', // this is the default; OIDC discovery is not fetched
|
|
124
|
+
allowInsecureJwksUrl: false, // https is required except on loopback hosts, unless this is set
|
|
125
|
+
leewaySeconds: 30, // exp and nbf only
|
|
126
|
+
tokenTypes: ['access'],
|
|
127
|
+
now: () => new Date(),
|
|
128
|
+
jwks: {
|
|
129
|
+
maxAge: 600000, // ms; a set older than this keeps answering while it reloads in the background
|
|
130
|
+
minRefreshInterval: 30000, // ms; at most one attempt per interval for a background reload, after a failure or for an unknown kid
|
|
131
|
+
maxStale: 3600000, // ms; how long after the last successful fetch a stale set is trusted
|
|
132
|
+
maxBytes: 1048576, // response body cap
|
|
133
|
+
timeout: 5000, // ms, an integer up to 2147483647
|
|
134
|
+
fetch, // replaces the global fetch, for proxies
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
An invalid configuration throws `TypeError` naming the option, at startup. `requireACR('gold')` throws too, because 'gold' has no level above 0. A freshness limit must truncate to a whole number of seconds from 1 to `Number.MAX_SAFE_INTEGER`. Leaving a `jwks` option out gives the default. An explicit `0` is a value: `maxAge: 0` starts a background reload on every verification, gated by `minRefreshInterval`, and `minRefreshInterval: 0` removes the gate, both legal and both discouraged. `maxBytes: 0`, `maxStale: 0` and `timeout: 0` throw.
|
|
140
|
+
|
|
141
|
+
Build one verifier per trust policy, at startup, because each owns a key cache.
|
|
142
|
+
|
|
143
|
+
### Stacking verifiers
|
|
144
|
+
|
|
145
|
+
An adapter's `authenticate` always verifies the presented token and replaces any claims on the request. `requireACR` and `requireFresh` act on existing claims only when their own verifier produced that exact object. Otherwise they verify the presented token themselves. A broad verifier upstream therefore cannot let a token outside a route's issuer, audiences or token types through on its level alone:
|
|
146
|
+
|
|
147
|
+
```js
|
|
148
|
+
app.use(broad.authenticate);
|
|
149
|
+
app.get('/restricted', narrow.requireACR('2'), handler);
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
A token outside `narrow`'s policy gets `invalid_token`, never a step-up. The identity rule is strict: two verifiers built from the same configuration re-verify each other's claims, and so does a spread or cloned claims object. A wrapper object that delegates to `verifier.verify` is a different identity for the same reason, and each case costs one more signature check and never a bypass. Keep one verifier per trust policy, and put the narrowest verifier's method directly on the route.
|
|
153
|
+
|
|
154
|
+
In Fastify, the broad hook goes on an encapsulated scope and the narrow one on the route:
|
|
155
|
+
|
|
156
|
+
```js
|
|
157
|
+
await app.register(async (scope) => {
|
|
158
|
+
scope.addHook('preHandler', scope.passflow.authenticate);
|
|
159
|
+
scope.get('/restricted', { preHandler: createFastifyHooks(narrowVerifier).requireACR('2') }, handler);
|
|
160
|
+
});
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
## Key set behaviour
|
|
164
|
+
|
|
165
|
+
The key set is fetched and cached by this package, with the same rules and defaults as the Go library `github.com/madappgang/passflow-go/auth`, though the Go options are `time.Duration` values and these are milliseconds.
|
|
166
|
+
|
|
167
|
+
- A token's key is looked up by `kid` and the key type its algorithm requires. An unknown `kid` causes at most one refetch per `minRefreshInterval`, and a bad signature never causes one. The request with the unknown `kid` waits for that refetch, or joins one already running, and one verification never makes two fetches.
|
|
168
|
+
- A set older than `maxAge` answers immediately and starts a background reload, unless one is running or `minRefreshInterval` has not passed since the last attempt. A request waits for a reload only when there is no set, or the set is past `maxStale`.
|
|
169
|
+
- A reload counts only when all of it succeeds: status 200, a body within `maxBytes`, a JSON key set, and every usable key imported and checked. A usable key must be public, with no private member, RSA of at least 2048 bits with an odd exponent from 3 to 2^31 - 1, or P-256. Each key member must be canonical unpadded base64url. A key that carries private material fails the reload.
|
|
170
|
+
- Anything short of a full success is one failed attempt, and a working set stays in place.
|
|
171
|
+
- While reloads fail, a stale set is trusted until `maxStale` after the last successful fetch. Then every request gets a 503 until a reload succeeds.
|
|
172
|
+
- A 503 never carries a challenge. A key rotation during an outage therefore does not look like a bad token.
|
|
173
|
+
|
|
174
|
+
**Serverless and edge runtimes.** A runtime that freezes or cancels work after a response may never finish a background reload. The set is then refreshed by the waiting path once it is past `maxStale`, or by an unknown `kid`.
|
|
175
|
+
|
|
176
|
+
**Rotation window.** A token signed by a key published less than `minRefreshInterval` after the previous fetch attempt is rejected as `key_unknown` until the gate opens, at most 30 seconds per instance with the defaults. Passflow signs with one key today, so only a deployment that rotates keys needs to call `await verifier.refresh()` from its rotation hook. It ignores the gate and reloads from a fetch that starts after the call, waiting out any fetch already running, so its success proves the set is newer than the call. It can therefore take up to twice `timeout`.
|
|
177
|
+
|
|
178
|
+
## License
|
|
179
|
+
|
|
180
|
+
Released under the MIT license, which is separate from the Passflow server's license.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/** The `passflow_tm` wire shape, camelCased. `tenantRoles` is decoded for raw access; `rolesOf` does not read it. */
|
|
2
|
+
type TenantMembership = {
|
|
3
|
+
tenantId: string;
|
|
4
|
+
tenantName?: string;
|
|
5
|
+
tenantRoles?: string[];
|
|
6
|
+
rootGroupId?: string;
|
|
7
|
+
groups?: Record<string, string[]>;
|
|
8
|
+
groupNames?: Record<string, string>;
|
|
9
|
+
};
|
|
10
|
+
type PassflowClaims = {
|
|
11
|
+
sub: string;
|
|
12
|
+
iss: string;
|
|
13
|
+
aud: string[];
|
|
14
|
+
exp: number;
|
|
15
|
+
iat?: number;
|
|
16
|
+
/** Whole Unix seconds. Absent when the claim is absent, not a number or beyond ±2^53. */
|
|
17
|
+
authTime?: number;
|
|
18
|
+
/** `'0'` when the claim is absent or not a string. */
|
|
19
|
+
acr: string;
|
|
20
|
+
amr: string[];
|
|
21
|
+
scopes: string[];
|
|
22
|
+
sid?: string;
|
|
23
|
+
type: string;
|
|
24
|
+
tenants: Record<string, TenantMembership>;
|
|
25
|
+
/** The verifier's clock at verification, in Unix seconds. `requireFresh` measures from it. */
|
|
26
|
+
verifiedAt: number;
|
|
27
|
+
raw: Record<string, unknown>;
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* The structural part of a verifier. Declared here, with `verifiedBy`, so that provenance needs nothing
|
|
31
|
+
* from the verifier module.
|
|
32
|
+
*/
|
|
33
|
+
interface Verifier {
|
|
34
|
+
verify(token: string): Promise<PassflowClaims>;
|
|
35
|
+
refresh(): Promise<void>;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* True only for the exact object `verifier.verify` returned. A spread or clone loses provenance, which
|
|
39
|
+
* costs one more verification and never a bypass. So does any wrapper or decorator object that delegates
|
|
40
|
+
* to `verifier.verify`: it is a different identity, and this verifier's adapter re-verifies claims it
|
|
41
|
+
* returns. Two verifiers built from the same configuration are different identities too.
|
|
42
|
+
*/
|
|
43
|
+
declare function verifiedBy(claims: PassflowClaims, verifier: Verifier): boolean;
|
|
44
|
+
/**
|
|
45
|
+
* The roles a user holds in a tenant: `groups[rootGroupId]`, the rule the server's `CalcTenantRoles` and
|
|
46
|
+
* `@passflow/core`'s `parseMembership` apply. A missing tenant, root group or map gives an empty list.
|
|
47
|
+
* So does an empty `rootGroupId`, unlike the server: no role is granted from a group keyed by "".
|
|
48
|
+
* `tenantRoles` is a copy the server derives from the same root group, and is not consulted.
|
|
49
|
+
*/
|
|
50
|
+
declare function rolesOf(claims: PassflowClaims, tenantId: string): string[];
|
|
51
|
+
export { verifiedBy as a, rolesOf as i, TenantMembership as n, Verifier as r, PassflowClaims as t };
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/** The `passflow_tm` wire shape, camelCased. `tenantRoles` is decoded for raw access; `rolesOf` does not read it. */
|
|
2
|
+
type TenantMembership = {
|
|
3
|
+
tenantId: string;
|
|
4
|
+
tenantName?: string;
|
|
5
|
+
tenantRoles?: string[];
|
|
6
|
+
rootGroupId?: string;
|
|
7
|
+
groups?: Record<string, string[]>;
|
|
8
|
+
groupNames?: Record<string, string>;
|
|
9
|
+
};
|
|
10
|
+
type PassflowClaims = {
|
|
11
|
+
sub: string;
|
|
12
|
+
iss: string;
|
|
13
|
+
aud: string[];
|
|
14
|
+
exp: number;
|
|
15
|
+
iat?: number;
|
|
16
|
+
/** Whole Unix seconds. Absent when the claim is absent, not a number or beyond ±2^53. */
|
|
17
|
+
authTime?: number;
|
|
18
|
+
/** `'0'` when the claim is absent or not a string. */
|
|
19
|
+
acr: string;
|
|
20
|
+
amr: string[];
|
|
21
|
+
scopes: string[];
|
|
22
|
+
sid?: string;
|
|
23
|
+
type: string;
|
|
24
|
+
tenants: Record<string, TenantMembership>;
|
|
25
|
+
/** The verifier's clock at verification, in Unix seconds. `requireFresh` measures from it. */
|
|
26
|
+
verifiedAt: number;
|
|
27
|
+
raw: Record<string, unknown>;
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* The structural part of a verifier. Declared here, with `verifiedBy`, so that provenance needs nothing
|
|
31
|
+
* from the verifier module.
|
|
32
|
+
*/
|
|
33
|
+
interface Verifier {
|
|
34
|
+
verify(token: string): Promise<PassflowClaims>;
|
|
35
|
+
refresh(): Promise<void>;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* True only for the exact object `verifier.verify` returned. A spread or clone loses provenance, which
|
|
39
|
+
* costs one more verification and never a bypass. So does any wrapper or decorator object that delegates
|
|
40
|
+
* to `verifier.verify`: it is a different identity, and this verifier's adapter re-verifies claims it
|
|
41
|
+
* returns. Two verifiers built from the same configuration are different identities too.
|
|
42
|
+
*/
|
|
43
|
+
declare function verifiedBy(claims: PassflowClaims, verifier: Verifier): boolean;
|
|
44
|
+
/**
|
|
45
|
+
* The roles a user holds in a tenant: `groups[rootGroupId]`, the rule the server's `CalcTenantRoles` and
|
|
46
|
+
* `@passflow/core`'s `parseMembership` apply. A missing tenant, root group or map gives an empty list.
|
|
47
|
+
* So does an empty `rootGroupId`, unlike the server: no role is granted from a group keyed by "".
|
|
48
|
+
* `tenantRoles` is a copy the server derives from the same root group, and is not consulted.
|
|
49
|
+
*/
|
|
50
|
+
declare function rolesOf(claims: PassflowClaims, tenantId: string): string[];
|
|
51
|
+
export { verifiedBy as a, rolesOf as i, TenantMembership as n, Verifier as r, PassflowClaims as t };
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
// src/acr.ts
|
|
2
|
+
const ACR_NONE = "0";
|
|
3
|
+
const AAL1 = "1";
|
|
4
|
+
const AAL2 = "2";
|
|
5
|
+
const AAL3 = "3";
|
|
6
|
+
const ACR_LEXICAL = /^[+-]?[0-9]+$/;
|
|
7
|
+
const INT64_MAX = 9223372036854775807n;
|
|
8
|
+
/**
|
|
9
|
+
* The ordinal level of an `acr` value, by the rule the server's `strconv.Atoi` applies on a 64-bit
|
|
10
|
+
* platform (`server_level` in the corpus): optional sign and any number of ASCII digits, the value
|
|
11
|
+
* must fit a signed 64-bit integer; a lexical failure, an out-of-range value, a negative value or a
|
|
12
|
+
* non-string is level 0.
|
|
13
|
+
*
|
|
14
|
+
* Internal. It returns `bigint` so that nothing on the decision path goes through a `Number`.
|
|
15
|
+
*/
|
|
16
|
+
function parseACRLevel(acr) {
|
|
17
|
+
if (typeof acr !== "string" || !ACR_LEXICAL.test(acr)) return 0n;
|
|
18
|
+
const level = BigInt(acr);
|
|
19
|
+
return level < 0n || level > INT64_MAX ? 0n : level;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Whether a token's `acr` meets the required level. Both sides are compared as exact 64-bit integers,
|
|
23
|
+
* and a required side that parses to level 0 is never satisfied: a wrong configuration string fails
|
|
24
|
+
* closed here as well as at `requireACR` construction.
|
|
25
|
+
*/
|
|
26
|
+
function acrSatisfies(acr, min) {
|
|
27
|
+
const required = parseACRLevel(min);
|
|
28
|
+
return required !== 0n && parseACRLevel(acr) >= required;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* The level as a `number`, for display and logging. It is exact only up to 2^53, and nothing in this
|
|
32
|
+
* library decides with it: use {@link acrSatisfies} to compare.
|
|
33
|
+
*/
|
|
34
|
+
function acrLevel(acr) {
|
|
35
|
+
return Number(parseACRLevel(acr));
|
|
36
|
+
}
|
|
37
|
+
// src/bearer.ts
|
|
38
|
+
const BEARER_PREFIX = /^bearer(?:[ \t]+|$)/i;
|
|
39
|
+
/**
|
|
40
|
+
* The token from an `Authorization` header. The scheme is matched case-insensitively (RFC 9110). Any
|
|
41
|
+
* other scheme, an absent header, and a `Bearer` with an empty or whitespace-only remainder all give
|
|
42
|
+
* `undefined`.
|
|
43
|
+
*/
|
|
44
|
+
function extractBearer(authorization) {
|
|
45
|
+
if (typeof authorization !== "string") return;
|
|
46
|
+
const header = authorization.trim();
|
|
47
|
+
const prefix = BEARER_PREFIX.exec(header);
|
|
48
|
+
if (prefix === null) return;
|
|
49
|
+
const token = header.slice(prefix[0].length);
|
|
50
|
+
return token === "" ? void 0 : token;
|
|
51
|
+
}
|
|
52
|
+
// src/challenge.ts
|
|
53
|
+
const MAX_AGE_ERROR = "[passflow] maxAgeSeconds must be a finite integer between 1 and Number.MAX_SAFE_INTEGER";
|
|
54
|
+
const FORMAT_MAX_AGE_ERROR = "[passflow] maxAge must be undefined or a finite number no greater than Number.MAX_SAFE_INTEGER";
|
|
55
|
+
const FORMAT_ACR_ERROR = "[passflow] acrValues must be undefined or a string";
|
|
56
|
+
/**
|
|
57
|
+
* The constructor rule for a freshness limit (`freshness.json#max_age_input`): a finite number,
|
|
58
|
+
* truncated toward zero, at least 1 and a safe integer, or a `TypeError`. The bound means `String()`
|
|
59
|
+
* always yields plain decimal, so a challenge can never carry `max_age=1e+21`, which the browser
|
|
60
|
+
* SDK's `Number.parseInt` reads as 1. The returned value is both what is enforced and what the
|
|
61
|
+
* challenge carries.
|
|
62
|
+
*/
|
|
63
|
+
function assertMaxAge(value) {
|
|
64
|
+
if (typeof value !== "number" || !Number.isFinite(value)) throw new TypeError(MAX_AGE_ERROR);
|
|
65
|
+
const seconds = Math.trunc(value);
|
|
66
|
+
if (seconds < 1 || !Number.isSafeInteger(seconds)) throw new TypeError(MAX_AGE_ERROR);
|
|
67
|
+
return seconds;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* The exact RFC 9470 `WWW-Authenticate` bytes the server's `SetInsufficientAuthChallenge` emits. This
|
|
71
|
+
* is a formatter, not a constructor: a `maxAge` that truncates to zero or below is omitted, as the
|
|
72
|
+
* server omits it, and it does not run {@link assertMaxAge}. A non-finite number, a non-number, or a
|
|
73
|
+
* positive value above `Number.MAX_SAFE_INTEGER` is a `TypeError`.
|
|
74
|
+
*
|
|
75
|
+
* `acrValues` is a space-separated list of acr tokens, inserted unescaped exactly as the server does.
|
|
76
|
+
*/
|
|
77
|
+
function formatChallenge(c) {
|
|
78
|
+
let header = "Bearer error=\"insufficient_user_authentication\"";
|
|
79
|
+
if (c.acrValues !== void 0) {
|
|
80
|
+
if (typeof c.acrValues !== "string") throw new TypeError(FORMAT_ACR_ERROR);
|
|
81
|
+
if (c.acrValues !== "") header += `, acr_values="${c.acrValues}"`;
|
|
82
|
+
}
|
|
83
|
+
if (c.maxAge !== void 0) {
|
|
84
|
+
if (typeof c.maxAge !== "number" || !Number.isFinite(c.maxAge)) throw new TypeError(FORMAT_MAX_AGE_ERROR);
|
|
85
|
+
const seconds = Math.trunc(c.maxAge);
|
|
86
|
+
if (seconds > 0) {
|
|
87
|
+
if (!Number.isSafeInteger(seconds)) throw new TypeError(FORMAT_MAX_AGE_ERROR);
|
|
88
|
+
header += `, max_age=${String(seconds)}`;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return header;
|
|
92
|
+
}
|
|
93
|
+
// src/claims.ts
|
|
94
|
+
const MAX_EXACT_SECONDS = 2 ** 53;
|
|
95
|
+
const provenance = /* @__PURE__ */ new WeakMap();
|
|
96
|
+
/** Internal: records that `verifier.verify` produced exactly this object. */
|
|
97
|
+
function recordProvenance(claims, verifier) {
|
|
98
|
+
provenance.set(claims, verifier);
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* True only for the exact object `verifier.verify` returned. A spread or clone loses provenance, which
|
|
102
|
+
* costs one more verification and never a bypass. So does any wrapper or decorator object that delegates
|
|
103
|
+
* to `verifier.verify`: it is a different identity, and this verifier's adapter re-verifies claims it
|
|
104
|
+
* returns. Two verifiers built from the same configuration are different identities too.
|
|
105
|
+
*/
|
|
106
|
+
function verifiedBy(claims, verifier) {
|
|
107
|
+
return provenance.get(claims) === verifier && verifier !== void 0;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Internal: a numeric date within ±2^53, the rule Go's `numericDate` applies. Beyond it a JSON number is no
|
|
111
|
+
* longer an exact count of seconds, so the claim reads as absent.
|
|
112
|
+
*/
|
|
113
|
+
function isExactSeconds(value) {
|
|
114
|
+
return typeof value === "number" && Math.abs(value) <= MAX_EXACT_SECONDS;
|
|
115
|
+
}
|
|
116
|
+
function isPlainObject(value) {
|
|
117
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
118
|
+
}
|
|
119
|
+
function isStringArray(value) {
|
|
120
|
+
return Array.isArray(value) && value.every((entry) => typeof entry === "string");
|
|
121
|
+
}
|
|
122
|
+
function stringsOf(value) {
|
|
123
|
+
return Array.isArray(value) ? value.filter((entry) => typeof entry === "string") : [];
|
|
124
|
+
}
|
|
125
|
+
/** A wrong JSON type means the membership cannot be read. `null` reads as absent, as Go's decoder does. */
|
|
126
|
+
function decodeMembership(value) {
|
|
127
|
+
if (!isPlainObject(value)) return;
|
|
128
|
+
const { tenant_id, tenant_name, tenant_roles, root_group_id, groups, group_names } = value;
|
|
129
|
+
const membership = { tenantId: "" };
|
|
130
|
+
if (tenant_id !== void 0 && tenant_id !== null) {
|
|
131
|
+
if (typeof tenant_id !== "string") return void 0;
|
|
132
|
+
membership.tenantId = tenant_id;
|
|
133
|
+
}
|
|
134
|
+
if (tenant_name !== void 0 && tenant_name !== null) {
|
|
135
|
+
if (typeof tenant_name !== "string") return void 0;
|
|
136
|
+
membership.tenantName = tenant_name;
|
|
137
|
+
}
|
|
138
|
+
if (tenant_roles !== void 0 && tenant_roles !== null) {
|
|
139
|
+
if (!isStringArray(tenant_roles)) return void 0;
|
|
140
|
+
membership.tenantRoles = tenant_roles;
|
|
141
|
+
}
|
|
142
|
+
if (root_group_id !== void 0 && root_group_id !== null) {
|
|
143
|
+
if (typeof root_group_id !== "string") return void 0;
|
|
144
|
+
membership.rootGroupId = root_group_id;
|
|
145
|
+
}
|
|
146
|
+
if (groups !== void 0 && groups !== null) {
|
|
147
|
+
if (!isPlainObject(groups)) return void 0;
|
|
148
|
+
const entries = Object.entries(groups).filter(([, roles]) => roles !== null);
|
|
149
|
+
if (!entries.every(([, roles]) => isStringArray(roles))) return void 0;
|
|
150
|
+
membership.groups = Object.fromEntries(entries);
|
|
151
|
+
}
|
|
152
|
+
if (group_names !== void 0 && group_names !== null) {
|
|
153
|
+
if (!isPlainObject(group_names)) return void 0;
|
|
154
|
+
const entries = Object.entries(group_names).filter(([, name]) => name !== null);
|
|
155
|
+
if (!entries.every(([, name]) => typeof name === "string")) return void 0;
|
|
156
|
+
membership.groupNames = Object.fromEntries(entries);
|
|
157
|
+
}
|
|
158
|
+
return membership;
|
|
159
|
+
}
|
|
160
|
+
/** A membership that fails to decode drops that tenant only: the shape is informational, the token stays valid. */
|
|
161
|
+
function decodeTenants(value) {
|
|
162
|
+
if (!isPlainObject(value)) return {};
|
|
163
|
+
const tenants = [];
|
|
164
|
+
for (const [tenantId, raw] of Object.entries(value)) {
|
|
165
|
+
const membership = decodeMembership(raw);
|
|
166
|
+
if (membership !== void 0) tenants.push([tenantId, membership]);
|
|
167
|
+
}
|
|
168
|
+
return Object.fromEntries(tenants);
|
|
169
|
+
}
|
|
170
|
+
/** Fail-closed claim extraction from a payload whose signature and registered claims already passed. */
|
|
171
|
+
function extractClaims(payload, verifiedAt) {
|
|
172
|
+
const { sub, iss, aud, exp, iat, auth_time: authTime, acr, amr, scopes, sid, type, passflow_tm: tenants } = payload;
|
|
173
|
+
const claims = {
|
|
174
|
+
sub: typeof sub === "string" ? sub : "",
|
|
175
|
+
iss: typeof iss === "string" ? iss : "",
|
|
176
|
+
aud: typeof aud === "string" ? [aud] : stringsOf(aud),
|
|
177
|
+
exp,
|
|
178
|
+
acr: typeof acr === "string" ? acr : "0",
|
|
179
|
+
amr: stringsOf(amr),
|
|
180
|
+
scopes: stringsOf(scopes),
|
|
181
|
+
type: typeof type === "string" ? type : "",
|
|
182
|
+
tenants: decodeTenants(tenants),
|
|
183
|
+
verifiedAt,
|
|
184
|
+
raw: payload
|
|
185
|
+
};
|
|
186
|
+
if (isExactSeconds(iat)) claims.iat = iat;
|
|
187
|
+
if (isExactSeconds(authTime)) claims.authTime = Math.trunc(authTime);
|
|
188
|
+
if (typeof sid === "string") claims.sid = sid;
|
|
189
|
+
return claims;
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* The roles a user holds in a tenant: `groups[rootGroupId]`, the rule the server's `CalcTenantRoles` and
|
|
193
|
+
* `@passflow/core`'s `parseMembership` apply. A missing tenant, root group or map gives an empty list.
|
|
194
|
+
* So does an empty `rootGroupId`, unlike the server: no role is granted from a group keyed by "".
|
|
195
|
+
* `tenantRoles` is a copy the server derives from the same root group, and is not consulted.
|
|
196
|
+
*/
|
|
197
|
+
function rolesOf(claims, tenantId) {
|
|
198
|
+
const { tenants } = claims;
|
|
199
|
+
if (!isPlainObject(tenants) || !Object.hasOwn(tenants, tenantId)) return [];
|
|
200
|
+
const { groups, rootGroupId } = tenants[tenantId];
|
|
201
|
+
if (!isPlainObject(groups) || !rootGroupId || !Object.hasOwn(groups, rootGroupId)) return [];
|
|
202
|
+
return [...groups[rootGroupId]];
|
|
203
|
+
}
|
|
204
|
+
// src/cors.ts
|
|
205
|
+
const CHALLENGE_HEADER = "WWW-Authenticate";
|
|
206
|
+
const CORS_EXPOSED_HEADERS = Object.freeze([CHALLENGE_HEADER]);
|
|
207
|
+
/**
|
|
208
|
+
* Appends `WWW-Authenticate` to an expose list unless it is already there, compared case-insensitively.
|
|
209
|
+
* Everything else is kept as given, `*` and duplicates included: `*` is not a wildcard on a
|
|
210
|
+
* credentialed request, so the explicit entry is what matters. This guarantees exactly one thing, that
|
|
211
|
+
* the header appears as an explicit entry.
|
|
212
|
+
*
|
|
213
|
+
* Internal. Each adapter wraps it under the option name its CORS middleware uses.
|
|
214
|
+
*/
|
|
215
|
+
function withChallengeHeader(existing) {
|
|
216
|
+
const entries = typeof existing === "string" ? existing.split(",").map((entry) => entry.trim()).filter((entry) => entry !== "") : [...existing ?? []];
|
|
217
|
+
return entries.some((entry) => entry.toLowerCase() === "WWW-Authenticate".toLowerCase()) ? entries : [...entries, CHALLENGE_HEADER];
|
|
218
|
+
}
|
|
219
|
+
// src/errors.ts
|
|
220
|
+
const MESSAGES = {
|
|
221
|
+
missing_token: "[passflow] no bearer token presented",
|
|
222
|
+
invalid_token: "[passflow] invalid token",
|
|
223
|
+
jwks_unavailable: "[passflow] key set unavailable"
|
|
224
|
+
};
|
|
225
|
+
/**
|
|
226
|
+
* An authentication failure. The message is fixed per code and carries nothing from the token, the
|
|
227
|
+
* request or the JWKS URL. A jose error is never attached as `cause`: jose's claim errors keep the
|
|
228
|
+
* whole payload on `.payload`.
|
|
229
|
+
*/
|
|
230
|
+
var PassflowAuthError = class extends Error {
|
|
231
|
+
code;
|
|
232
|
+
reason;
|
|
233
|
+
status;
|
|
234
|
+
constructor(code, reason, options) {
|
|
235
|
+
super(reason === void 0 ? MESSAGES[code] : `${MESSAGES[code]}: ${reason}`, options);
|
|
236
|
+
this.name = "PassflowAuthError";
|
|
237
|
+
this.code = code;
|
|
238
|
+
this.status = code === "jwks_unavailable" ? 503 : 401;
|
|
239
|
+
if (reason !== void 0) this.reason = reason;
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
/**
|
|
243
|
+
* A programming error, such as reading the claims before any middleware ran. It is deliberately not
|
|
244
|
+
* a `PassflowAuthError`: those mean 401/503, which the browser SDK acts on, and this must reach the
|
|
245
|
+
* framework's error handling as a 500.
|
|
246
|
+
*/
|
|
247
|
+
var PassflowUsageError = class extends Error {
|
|
248
|
+
constructor(message) {
|
|
249
|
+
super(message);
|
|
250
|
+
this.name = "PassflowUsageError";
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
/**
|
|
254
|
+
* True for a `PassflowAuthError`, including one from the other copy of this package when the ESM
|
|
255
|
+
* and CJS builds are both loaded, where `instanceof` alone would say no.
|
|
256
|
+
*/
|
|
257
|
+
function isPassflowAuthError(e) {
|
|
258
|
+
if (e instanceof PassflowAuthError) return true;
|
|
259
|
+
if (!(e instanceof Error) || e.name !== "PassflowAuthError") return false;
|
|
260
|
+
const { code } = e;
|
|
261
|
+
return code === "missing_token" || code === "invalid_token" || code === "jwks_unavailable";
|
|
262
|
+
}
|
|
263
|
+
// src/evaluate.ts
|
|
264
|
+
const CACHE_CONTROL = "Cache-Control";
|
|
265
|
+
const NO_STORE = "no-store";
|
|
266
|
+
const reject = (status, wwwAuthenticate) => ({
|
|
267
|
+
kind: "reject",
|
|
268
|
+
status,
|
|
269
|
+
headers: wwwAuthenticate === void 0 ? { [CACHE_CONTROL]: NO_STORE } : {
|
|
270
|
+
[CHALLENGE_HEADER]: wwwAuthenticate,
|
|
271
|
+
[CACHE_CONTROL]: NO_STORE
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
/**
|
|
275
|
+
* Authentication failures never carry the step-up error: the browser SDK acts only on the step-up challenge,
|
|
276
|
+
* and every other response, a plain 401 or a 503 included, reaches the caller's code unchanged.
|
|
277
|
+
*/
|
|
278
|
+
function rejectAuthentication(code) {
|
|
279
|
+
switch (code) {
|
|
280
|
+
case "missing_token": return reject(401, "Bearer");
|
|
281
|
+
case "invalid_token": return reject(401, "Bearer error=\"invalid_token\"");
|
|
282
|
+
case "jwks_unavailable": return reject(503);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Validates a requirement and returns it with the effective freshness limit. Throws `TypeError`: this is a
|
|
287
|
+
* programming error that should stop the process at route registration, not become a `Decision`.
|
|
288
|
+
*
|
|
289
|
+
* Internal: `requireACR` and `requireFresh` call it at construction.
|
|
290
|
+
*/
|
|
291
|
+
function assertRequirement(requirement) {
|
|
292
|
+
if (typeof requirement === "object" && requirement !== null) {
|
|
293
|
+
if (requirement.kind === "acr") {
|
|
294
|
+
if (parseACRLevel(requirement.min) === 0n) throw new TypeError("[passflow] the required ACR must parse to a level above 0");
|
|
295
|
+
return requirement;
|
|
296
|
+
}
|
|
297
|
+
if (requirement.kind === "fresh") return {
|
|
298
|
+
kind: "fresh",
|
|
299
|
+
maxAgeSeconds: assertMaxAge(requirement.maxAgeSeconds)
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
throw new TypeError("[passflow] a requirement is { kind: \"acr\", min } or { kind: \"fresh\", maxAgeSeconds }");
|
|
303
|
+
}
|
|
304
|
+
function isFresh(claims, maxAgeSeconds) {
|
|
305
|
+
const { authTime, verifiedAt } = claims;
|
|
306
|
+
if (!isExactSeconds(authTime)) return false;
|
|
307
|
+
return (typeof verifiedAt === "number" && Number.isFinite(verifiedAt) && verifiedAt > 0 ? Math.floor(verifiedAt) : Math.floor(Date.now() / 1e3)) - Math.trunc(authTime) <= maxAgeSeconds;
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Decides one requirement against claims. No claims means the middleware was composed wrongly, so the answer
|
|
311
|
+
* is a 500 with no challenge: never a step-up, which the SDK would try to satisfy, and never a 401, because a
|
|
312
|
+
* wiring bug must not be reported as a bad token. Throws `TypeError` on an invalid requirement.
|
|
313
|
+
*/
|
|
314
|
+
function evaluate(claims, requirement) {
|
|
315
|
+
const checked = assertRequirement(requirement);
|
|
316
|
+
if (claims === void 0 || claims === null) return reject(500);
|
|
317
|
+
if (checked.kind === "acr") return acrSatisfies(claims.acr, checked.min) ? { kind: "allow" } : reject(401, formatChallenge({ acrValues: checked.min }));
|
|
318
|
+
return isFresh(claims, checked.maxAgeSeconds) ? { kind: "allow" } : reject(401, formatChallenge({ maxAge: checked.maxAgeSeconds }));
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Authenticates a request. `existing` claims are reused only when `verifier` produced that exact object;
|
|
322
|
+
* otherwise the presented header is verified with `verifier`, so a token outside this verifier's trust policy
|
|
323
|
+
* is rejected as not valid here, never as insufficient. Never rejects: a failure is a `{ reject }` value.
|
|
324
|
+
*/
|
|
325
|
+
async function authenticate(verifier, authorization, existing) {
|
|
326
|
+
if (existing !== void 0 && existing !== null && verifiedBy(existing, verifier)) return { claims: existing };
|
|
327
|
+
const token = extractBearer(authorization);
|
|
328
|
+
if (token === void 0) return { reject: rejectAuthentication("missing_token") };
|
|
329
|
+
try {
|
|
330
|
+
return { claims: await verifier.verify(token) };
|
|
331
|
+
} catch (error) {
|
|
332
|
+
return { reject: isPassflowAuthError(error) ? rejectAuthentication(error.code) : reject(500) };
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Internal: one adapter step. Authenticates, then applies the requirement if there is one.
|
|
337
|
+
*/
|
|
338
|
+
async function guard(verifier, authorization, existing, requirement) {
|
|
339
|
+
const result = await authenticate(verifier, authorization, existing);
|
|
340
|
+
if ("reject" in result || requirement === void 0) return result;
|
|
341
|
+
const decision = evaluate(result.claims, requirement);
|
|
342
|
+
return decision.kind === "reject" ? { reject: decision } : result;
|
|
343
|
+
}
|
|
344
|
+
/** Internal: the adapters fail at construction when handed something that cannot verify. */
|
|
345
|
+
function assertVerifier(verifier) {
|
|
346
|
+
if (typeof verifier !== "object" || verifier === null || typeof verifier.verify !== "function") throw new TypeError("[passflow] an adapter needs a verifier from createVerifier");
|
|
347
|
+
}
|
|
348
|
+
export { acrLevel as C, ACR_NONE as S, formatChallenge as _, guard as a, AAL2 as b, isPassflowAuthError as c, withChallengeHeader as d, extractClaims as f, assertMaxAge as g, verifiedBy as h, evaluate as i, CHALLENGE_HEADER as l, rolesOf as m, assertVerifier as n, PassflowAuthError as o, recordProvenance as p, authenticate as r, PassflowUsageError as s, assertRequirement as t, CORS_EXPOSED_HEADERS as u, extractBearer as v, acrSatisfies as w, AAL3 as x, AAL1 as y };
|