@owlmeans/auth-token 0.1.18-rc.8 → 0.1.18
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/README.md +164 -1
- package/agent-meta/manifest.json +3 -3
- package/agent-meta/skills/auth-token/SKILL.md +18 -4
- package/build/carrier.d.ts.map +1 -1
- package/build/carrier.js +2 -0
- package/build/carrier.js.map +1 -1
- package/build/format/types.d.ts +27 -0
- package/build/format/types.d.ts.map +1 -0
- package/build/format/types.js +2 -0
- package/build/format/types.js.map +1 -0
- package/build/format.d.ts +3 -21
- package/build/format.d.ts.map +1 -1
- package/build/format.js +22 -33
- package/build/format.js.map +1 -1
- package/build/index.d.ts +1 -0
- package/build/index.d.ts.map +1 -1
- package/build/types.d.ts +25 -3
- package/build/types.d.ts.map +1 -1
- package/package.json +10 -10
- package/src/carrier.ts +8 -0
- package/src/format/types.ts +27 -0
- package/src/format.ts +28 -36
- package/src/index.ts +1 -0
- package/src/types.ts +24 -3
- package/tests/carrier.spec.ts +20 -0
- package/tests/format.spec.ts +17 -17
package/README.md
CHANGED
|
@@ -1,3 +1,166 @@
|
|
|
1
|
+
# @owlmeans/auth-token
|
|
2
|
+
|
|
3
|
+
The contract half of long-lived access tokens (API keys) on the OwlMeans auth rails: the stored
|
|
4
|
+
record shape, the token format and its deployment prefix, the three management route declarations
|
|
5
|
+
with their AJV schemas, `Authorization` header parsing that accepts `Bearer`, and a client-side
|
|
6
|
+
carrier guard that presents a token it was handed. A shared package imports it to declare the token
|
|
7
|
+
routes and types; a CLI, a test or an MCP connector imports it for the carrier guard. It stores and
|
|
8
|
+
verifies nothing — the token store, the verifying guard and the handlers are
|
|
9
|
+
`@owlmeans/server-auth-token`, and the management UI is `@owlmeans/web-auth-token`. Browser sessions
|
|
10
|
+
are `@owlmeans/client-auth`, not this package.
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
bun add @owlmeans/auth-token@^0.1.18
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Concepts
|
|
19
|
+
|
|
20
|
+
- **Token** — `<prefix><base58 of 24 random bytes>`. The prefix (`owl_` by default, overridden per
|
|
21
|
+
deployment) makes a token claimable: a guard matches only values that start with it, so an access
|
|
22
|
+
token and a session bearer share the `Authorization` header without shadowing each other.
|
|
23
|
+
- **Record** — `AccessTokenRecord`: the SHA-256 `hash` of the plaintext (never the plaintext), a
|
|
24
|
+
`display` form, the owning `userId`/`profileId`/`entityId`, `scopes`, `role`, timestamps and an
|
|
25
|
+
optional OAuth `audience`.
|
|
26
|
+
- **Display** — the prefix plus `AUTH_TOKEN_DISPLAY_LENGTH` (8) characters of the secret; enough to
|
|
27
|
+
tell tokens apart, useless as a credential. The plaintext appears once, in `IssuedAccessToken`.
|
|
28
|
+
- **Management surface** — `list`, `create` and `revoke` only. There is no update: scopes and
|
|
29
|
+
lifetime are fixed at issuance.
|
|
30
|
+
- **Audience** — `audience?: string[]` is set only on tokens minted through an OAuth grant; a guard
|
|
31
|
+
configured with `resources` admits such a token only when the lists intersect.
|
|
32
|
+
- **Carrier guard** — a client `GuardService` with no session, refresh or storage; `authMiddleware`
|
|
33
|
+
stamps its `authenticated()` answer onto the request header.
|
|
34
|
+
|
|
35
|
+
## Usage
|
|
36
|
+
|
|
37
|
+
### Declare the management routes
|
|
38
|
+
|
|
39
|
+
Mount the base under an account section so the ownership gate already guarding a person's settings
|
|
40
|
+
guards their tokens too.
|
|
41
|
+
|
|
42
|
+
```typescript
|
|
43
|
+
import { makeAuthTokenEntrypoints } from '@owlmeans/auth-token'
|
|
44
|
+
|
|
45
|
+
export const tokenEntrypoints = makeAuthTokenEntrypoints({ parent: account.base, path: '/tokens' })
|
|
46
|
+
|
|
47
|
+
context.registerEntrypoints(tokenEntrypoints)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
A base without a parent carries `opts.guard` instead:
|
|
51
|
+
|
|
52
|
+
```typescript
|
|
53
|
+
import { GUARD_AUTH_TOKEN, makeAuthTokenEntrypoints } from '@owlmeans/auth-token'
|
|
54
|
+
|
|
55
|
+
const tokens = makeAuthTokenEntrypoints({ guard: GUARD_AUTH_TOKEN })
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### Authenticate a non-browser client with a token
|
|
59
|
+
|
|
60
|
+
Register the carrier under the alias the routes already name, and unchanged route declarations work
|
|
61
|
+
from a CLI or a connector.
|
|
62
|
+
|
|
63
|
+
```typescript
|
|
64
|
+
import { DEFAULT_GUARD, authMiddleware } from '@owlmeans/auth-common'
|
|
65
|
+
import { makeTokenCarrierGuard } from '@owlmeans/auth-token'
|
|
66
|
+
|
|
67
|
+
context.registerService(makeTokenCarrierGuard(DEFAULT_GUARD, {
|
|
68
|
+
token: () => process.env.OWL_TOKEN ?? '', // a thunk is re-read on every request
|
|
69
|
+
scheme: 'bearer', // default 'auth-token' → `AUTH-TOKEN <token>`
|
|
70
|
+
onRejected: async () => { await signInAgain() },
|
|
71
|
+
}))
|
|
72
|
+
context.registerMiddleware(authMiddleware)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Parse and display tokens
|
|
76
|
+
|
|
77
|
+
```typescript
|
|
78
|
+
import { AUTH_TOKEN_DEFAULT_PREFIX, BEARER_SCHEME, AUTH_TOKEN_SCHEME, tokenFormatHelper } from '@owlmeans/auth-token'
|
|
79
|
+
|
|
80
|
+
const header = tokenFormatHelper.parseAuthorizationHeader(req.headers.authorization)
|
|
81
|
+
if (header != null && (header.scheme === BEARER_SCHEME || header.scheme === AUTH_TOKEN_SCHEME)
|
|
82
|
+
&& tokenFormatHelper.isAccessToken(header.value, AUTH_TOKEN_DEFAULT_PREFIX)) {
|
|
83
|
+
// claim it
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
tokenFormatHelper.displayOf('owl_ABCDEFGHIJKLMNOP', 'owl_') // 'owl_ABCDEFGH'
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### Create a token from a client
|
|
90
|
+
|
|
91
|
+
```typescript
|
|
92
|
+
import type { CreateAccessToken, IssuedAccessToken } from '@owlmeans/auth-token'
|
|
93
|
+
|
|
94
|
+
const body: CreateAccessToken = { name: 'CI deploy', scopes: ['project:read'], expiresIn: 30 * 24 * 3600 }
|
|
95
|
+
const issued: IssuedAccessToken = await context.entrypoint(tokenEntrypoints.create).call({ body })
|
|
96
|
+
// issued.token is the only time the plaintext is available
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## API
|
|
100
|
+
|
|
101
|
+
### Functions and helpers
|
|
102
|
+
|
|
103
|
+
| Symbol | Kind | Purpose |
|
|
104
|
+
|---|---|---|
|
|
105
|
+
| `makeAuthTokenEntrypoints(opts?)` | function | `{ base, list, create, revoke }` protocols: `GET /`, `POST /`, `DELETE /:id` under `opts.path ?? '/tokens'`; `opts.parent` or `opts.guard` for the base |
|
|
106
|
+
| `makeTokenCarrierGuard(alias, opts)` | function | client `GuardService`; `authenticated()` → `AUTH-TOKEN <t>` or `Bearer <t>`, `null` for an empty token; `update()` calls `opts.onRejected` |
|
|
107
|
+
| `tokenFormatHelper`, `createTokenFormatHelper()` | helper | the three members below |
|
|
108
|
+
| `tokenFormatHelper.parseAuthorizationHeader(header)` | member | `{ scheme, value }` with scheme lower-cased; first of a folded array; `null` without a value |
|
|
109
|
+
| `tokenFormatHelper.isAccessToken(value, prefix)` | member | value starts with the prefix and is longer than it |
|
|
110
|
+
| `tokenFormatHelper.displayOf(token, prefix)` | member | prefix + 8 characters of the secret |
|
|
111
|
+
| `CreateAccessTokenSchema` | AJV schema | `name` 1–64, `scopes?` ≤ 32 items, `expiresIn?` integer seconds 60…366 days |
|
|
112
|
+
| `AccessTokenParamsSchema` | AJV schema | `{ id }`, 1–128 characters |
|
|
113
|
+
|
|
114
|
+
### Constants
|
|
115
|
+
|
|
116
|
+
| Symbol | Value |
|
|
117
|
+
|---|---|
|
|
118
|
+
| `GUARD_AUTH_TOKEN` | `'guard:auth-token'` |
|
|
119
|
+
| `AUTH_TOKEN_RESOURCE`, `AUTH_TOKEN_COLLECTION` | `'auth-token:token'`, `'access-token'` |
|
|
120
|
+
| `AUTH_TOKEN_DEFAULT_PREFIX` | `'owl_'` |
|
|
121
|
+
| `AUTH_TOKEN_SECRET_BYTES`, `AUTH_TOKEN_DISPLAY_LENGTH` | `24`, `8` |
|
|
122
|
+
| `AUTH_TOKEN_TOUCH_INTERVAL` | 5 minutes in ms — how often `lastUsedAt` is written |
|
|
123
|
+
| `AUTH_TOKEN_MAX_TTL` | 366 days in ms |
|
|
124
|
+
| `AUTH_TOKEN_NAME_MAX` | `64` |
|
|
125
|
+
| `authToken` | frozen route aliases `{ base, list, create, revoke }` (`'auth-token:*'`) |
|
|
126
|
+
| `AUTH_TOKEN_SCHEME`, `BEARER_SCHEME` | `'auth-token'`, `'bearer'` (lower-cased, for comparison) |
|
|
127
|
+
|
|
128
|
+
### Types
|
|
129
|
+
|
|
130
|
+
| Symbol | Purpose |
|
|
131
|
+
|---|---|
|
|
132
|
+
| `AccessTokenRecord` | the stored record (hash, display, owner ids, scopes, role, dates, `audience?`) |
|
|
133
|
+
| `AccessTokenView` | the record without `hash` — what a caller may see |
|
|
134
|
+
| `CreateAccessToken` | `{ name, scopes?, expiresIn? }` — `expiresIn` in seconds |
|
|
135
|
+
| `IssuedAccessToken` | `{ token, record }` |
|
|
136
|
+
| `AccessTokenList`, `AccessTokenParams` | `{ items }`, `{ id }` |
|
|
137
|
+
| `TokenCarrierOptions` | `{ token: string \| () => string \| Promise<string>, scheme?, onRejected? }` |
|
|
138
|
+
| `AuthTokenEntrypointOptions`, `AuthTokenEntrypoints` | `makeAuthTokenEntrypoints` input and output |
|
|
139
|
+
| `AuthorizationHeader`, `TokenFormatHelper` | parsed header and the format helper interface |
|
|
140
|
+
|
|
141
|
+
## Common pitfalls
|
|
142
|
+
|
|
143
|
+
- Do not use `extractAuthToken` from `@owlmeans/auth-common` for these tokens — it compares against
|
|
144
|
+
the upper-cased type and never matches `Bearer`; use `tokenFormatHelper.parseAuthorizationHeader`.
|
|
145
|
+
- `expiresIn` is seconds, not days; omit it for "never" rather than sending `0`.
|
|
146
|
+
- The plaintext is returned only by `create`; never store or log it. Lists show `display`.
|
|
147
|
+
- Give each deployment its own prefix so two deployments never claim each other's tokens.
|
|
148
|
+
- Pass a thunk as `token` in long-running processes so a reconfigured credential is picked up.
|
|
149
|
+
- Do not add an update route: a token that can be widened after issuance is a grant nobody can
|
|
150
|
+
reason about.
|
|
151
|
+
|
|
152
|
+
The `auth-token` skill covers the prefix rule, header parsing, the carrier guard and the management
|
|
153
|
+
surface; the `server-auth-token` skill covers the store and the verifying guard.
|
|
154
|
+
|
|
155
|
+
## Related packages
|
|
156
|
+
|
|
157
|
+
- [`@owlmeans/server-auth-token`](../server-auth-token) — token store, verifying guard with audience admission, handlers
|
|
158
|
+
- [`@owlmeans/web-auth-token`](../web-auth-token) — the token-management panel and hook
|
|
159
|
+
- [`@owlmeans/server-oauth`](../server-oauth) — mints audience-scoped tokens through OAuth grants
|
|
160
|
+
- [`@owlmeans/auth-common`](../auth-common) — `authMiddleware`, `DEFAULT_GUARD`
|
|
161
|
+
- [`@owlmeans/cli-auth`](../cli-auth) — browser sign-in for a CLI, whose result a carrier guard can present
|
|
162
|
+
- [`@owlmeans/entrypoint`](../entrypoint) — protocol declarations and `GuardService`
|
|
163
|
+
|
|
1
164
|
<!-- owlmeans:agent-guidance:start -->
|
|
2
165
|
## Agent guidance
|
|
3
166
|
|
|
@@ -6,7 +169,7 @@ This package ships embedded agent skills under `agent-meta/`. After installing y
|
|
|
6
169
|
your project's skill store (`.agents/skills/`):
|
|
7
170
|
|
|
8
171
|
```sh
|
|
9
|
-
npx @owlmeans/agent-skills@^0.1.18
|
|
172
|
+
npx @owlmeans/agent-skills@^0.1.18
|
|
10
173
|
```
|
|
11
174
|
|
|
12
175
|
The embedded files are version-matched to this package release. Do not edit them
|
package/agent-meta/manifest.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
|
-
"schemaVersion":
|
|
2
|
+
"schemaVersion": 3,
|
|
3
3
|
"package": "@owlmeans/auth-token",
|
|
4
|
-
"version": "0.1.18
|
|
5
|
-
"generatedAt": "2026-
|
|
4
|
+
"version": "0.1.18",
|
|
5
|
+
"generatedAt": "2026-10-28T00:31:34.748Z",
|
|
6
6
|
"canonicalRepo": "https://github.com/owlmeans/common",
|
|
7
7
|
"entries": [
|
|
8
8
|
{
|
|
@@ -8,7 +8,7 @@ user-invocable: false
|
|
|
8
8
|
# @owlmeans/auth-token
|
|
9
9
|
|
|
10
10
|
**Layer:** Auth shared
|
|
11
|
-
**Install:** `"@owlmeans/auth-token": "^0.1.18
|
|
11
|
+
**Install:** `"@owlmeans/auth-token": "^0.1.18"` in `dependencies`
|
|
12
12
|
|
|
13
13
|
The contract half of long-lived access tokens: the record shape, the route declarations, the
|
|
14
14
|
format helpers, and one client-side guard that presents a token it was handed. The server half —
|
|
@@ -20,7 +20,7 @@ UI is `@owlmeans/web-auth-token`.
|
|
|
20
20
|
| Export | Description |
|
|
21
21
|
|--------|-------------|
|
|
22
22
|
| `makeAuthTokenEntrypoints(opts?)` | The three routes (`list` GET, `create` POST, `revoke` DELETE `/:id`) under a `/tokens` base. `opts`: `parent`, `path`, `guard` |
|
|
23
|
-
| `makeTokenCarrierGuard(alias, opts)` | A client `GuardService` that presents one token. `opts.token` is a value or a thunk; `opts.scheme` is `'auth-token'` (default) or `'bearer'` |
|
|
23
|
+
| `makeTokenCarrierGuard(alias, opts)` | A client `GuardService` that presents one token. `opts.token` is a value or a thunk; `opts.scheme` is `'auth-token'` (default) or `'bearer'`; `opts.onRejected` hears a 401 |
|
|
24
24
|
| `parseAuthorizationHeader(header)` | `{ scheme, value }` with the scheme lower-cased, or `null` |
|
|
25
25
|
| `isAccessToken(value, prefix)` · `displayOf(token, prefix)` | Whether a value is one of this deployment's tokens; the half of it that may be shown again |
|
|
26
26
|
| `CreateAccessTokenSchema` · `AccessTokenParamsSchema` | The ajv body/params filters |
|
|
@@ -39,6 +39,12 @@ the guard answers `match` only for a value that starts with it — so an access
|
|
|
39
39
|
session bearer arrive under the same `Authorization` header without either guard shadowing the
|
|
40
40
|
other, and two deployments never mistake each other's credentials for their own.
|
|
41
41
|
|
|
42
|
+
**`AccessTokenRecord.audience?: string[]`** names the resources a token was issued FOR when it came
|
|
43
|
+
through an OAuth grant (`@owlmeans/server-oauth`, RFC 8707 applied at OUR guard — the token is an
|
|
44
|
+
opaque secret, not a JWT). Absent on every hand-minted token, which is admitted everywhere its scopes
|
|
45
|
+
reach; a non-empty audience is admitted only by a guard whose `resources` intersect it (see
|
|
46
|
+
[[server-auth-token]]).
|
|
47
|
+
|
|
42
48
|
The plaintext exists exactly once, in the create response. What is stored is its hash; what a list
|
|
43
49
|
shows forever after is `display` — the prefix plus 8 characters, enough to tell two of your own
|
|
44
50
|
tokens apart and far too little to replay.
|
|
@@ -66,7 +72,14 @@ context.registerMiddleware(authMiddleware)
|
|
|
66
72
|
|
|
67
73
|
There is no session, no refresh and no storage: the token is a long-lived credential the caller was
|
|
68
74
|
handed. `opts.token` may be a thunk because a long-running process reads it from an environment
|
|
69
|
-
variable and must not cache it past a reconfiguration
|
|
75
|
+
variable or a credentials file and must not cache it past a reconfiguration — or past a sign-in that
|
|
76
|
+
has not happened yet.
|
|
77
|
+
|
|
78
|
+
**The carrier answers `update()`.** On a 401 for a request that presented this guard's bearer,
|
|
79
|
+
`@owlmeans/api` calls `service('<alias>').update(undefined)` to clear a browser session; a carrier
|
|
80
|
+
with no `update` made that a bare `TypeError` instead of the auth failure already being reported. The
|
|
81
|
+
carrier's `update` is a no-op that calls `opts.onRejected` — the hook a credential holder uses to
|
|
82
|
+
forget a dead token and sign in again (or to report it, when an operator supplied it by hand).
|
|
70
83
|
|
|
71
84
|
## The management surface is deliberately not a CRUD
|
|
72
85
|
|
|
@@ -92,7 +105,8 @@ sending a zero.
|
|
|
92
105
|
|
|
93
106
|
## Related
|
|
94
107
|
|
|
95
|
-
- [[server-auth-token]] — the store, the verifying guard, the handlers and the coguard
|
|
108
|
+
- [[server-auth-token]] — the store, the verifying guard (audience admission), issuance, the handlers and the coguard
|
|
109
|
+
- [[oauth]] — the OAuth vocabulary that mints audience-scoped tokens
|
|
96
110
|
- [[web-auth-token]] — the management panel and its hook
|
|
97
111
|
- [[auth-protocol]] — where long-lived tokens sit among the other authentication paths
|
|
98
112
|
- [[auth-common]] — `authMiddleware`, `DEFAULT_GUARD`, `extractAuthToken`
|
package/build/carrier.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"carrier.d.ts","sourceRoot":"","sources":["../src/carrier.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,sBAAsB,CAAA;AAExD,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,YAAY,CAAA;AAErD;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,qBAAqB,UAAW,MAAM,QAAQ,mBAAmB,KAAG,
|
|
1
|
+
{"version":3,"file":"carrier.d.ts","sourceRoot":"","sources":["../src/carrier.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,sBAAsB,CAAA;AAExD,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,YAAY,CAAA;AAErD;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,qBAAqB,UAAW,MAAM,QAAQ,mBAAmB,KAAG,YA8BhF,CAAA"}
|
package/build/carrier.js
CHANGED
|
@@ -26,6 +26,8 @@ export const makeTokenCarrierGuard = (alias, opts) => {
|
|
|
26
26
|
return token == null ? null : `${scheme} ${token}`;
|
|
27
27
|
},
|
|
28
28
|
}, service => async () => { service.initialized = true; });
|
|
29
|
+
service.update =
|
|
30
|
+
async () => { await opts.onRejected?.(); };
|
|
29
31
|
return service;
|
|
30
32
|
};
|
|
31
33
|
//# sourceMappingURL=carrier.js.map
|
package/build/carrier.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"carrier.js","sourceRoot":"","sources":["../src/carrier.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAA;AAEjD,OAAO,EAAE,iBAAiB,EAAE,aAAa,EAAE,MAAM,aAAa,CAAA;AAG9D;;;;;;;;;;;GAWG;AACH,MAAM,CAAC,MAAM,qBAAqB,GAAG,CAAC,KAAa,EAAE,IAAyB,EAAgB,EAAE;IAC9F,MAAM,MAAM,GAAG,IAAI,CAAC,MAAM,KAAK,aAAa,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,iBAAiB,CAAC,WAAW,EAAE,CAAA;IAEzF,MAAM,OAAO,GAAG,KAAK,IAA4B,EAAE;QACjD,MAAM,KAAK,GAAG,OAAO,IAAI,CAAC,KAAK,KAAK,UAAU,CAAC,CAAC,CAAC,MAAM,IAAI,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAA;QAEhF,OAAO,KAAK,IAAI,IAAI,IAAI,KAAK,KAAK,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,KAAK,CAAA;IACrD,CAAC,CAAA;IAED,MAAM,OAAO,GAAiB,aAAa,CAAe,KAAK,EAAE;QAC/D,KAAK,EAAE,KAAK,IAAI,EAAE,CAAC,MAAM,OAAO,EAAE,IAAI,IAAI;QAE1C,MAAM,EAAE,KAAK,IAAO,EAAE,CAAC,KAAK,CAAM;QAElC,aAAa,EAAE,KAAK,IAAI,EAAE;YACxB,MAAM,KAAK,GAAG,MAAM,OAAO,EAAE,CAAA;YAE7B,OAAO,KAAK,IAAI,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,GAAG,MAAM,IAAI,KAAK,EAAE,CAAA;QACpD,CAAC;KACF,EAAE,OAAO,CAAC,EAAE,CAAC,KAAK,IAAI,EAAE,GAAG,OAAO,CAAC,WAAW,GAAG,IAAI,CAAA,CAAC,CAAC,CAAC,CAAA;
|
|
1
|
+
{"version":3,"file":"carrier.js","sourceRoot":"","sources":["../src/carrier.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAA;AAEjD,OAAO,EAAE,iBAAiB,EAAE,aAAa,EAAE,MAAM,aAAa,CAAA;AAG9D;;;;;;;;;;;GAWG;AACH,MAAM,CAAC,MAAM,qBAAqB,GAAG,CAAC,KAAa,EAAE,IAAyB,EAAgB,EAAE;IAC9F,MAAM,MAAM,GAAG,IAAI,CAAC,MAAM,KAAK,aAAa,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,iBAAiB,CAAC,WAAW,EAAE,CAAA;IAEzF,MAAM,OAAO,GAAG,KAAK,IAA4B,EAAE;QACjD,MAAM,KAAK,GAAG,OAAO,IAAI,CAAC,KAAK,KAAK,UAAU,CAAC,CAAC,CAAC,MAAM,IAAI,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAA;QAEhF,OAAO,KAAK,IAAI,IAAI,IAAI,KAAK,KAAK,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,KAAK,CAAA;IACrD,CAAC,CAAA;IAED,MAAM,OAAO,GAAiB,aAAa,CAAe,KAAK,EAAE;QAC/D,KAAK,EAAE,KAAK,IAAI,EAAE,CAAC,MAAM,OAAO,EAAE,IAAI,IAAI;QAE1C,MAAM,EAAE,KAAK,IAAO,EAAE,CAAC,KAAK,CAAM;QAElC,aAAa,EAAE,KAAK,IAAI,EAAE;YACxB,MAAM,KAAK,GAAG,MAAM,OAAO,EAAE,CAAA;YAE7B,OAAO,KAAK,IAAI,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,GAAG,MAAM,IAAI,KAAK,EAAE,CAAA;QACpD,CAAC;KACF,EAAE,OAAO,CAAC,EAAE,CAAC,KAAK,IAAI,EAAE,GAAG,OAAO,CAAC,WAAW,GAAG,IAAI,CAAA,CAAC,CAAC,CAAC,CAOxD;IAAC,OAAmF,CAAC,MAAM;QAC1F,KAAK,IAAI,EAAE,GAAG,MAAM,IAAI,CAAC,UAAU,EAAE,EAAE,CAAA,CAAC,CAAC,CAAA;IAE3C,OAAO,OAAO,CAAA;AAChB,CAAC,CAAA"}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** An `Authorization` header taken apart: the scheme lower-cased, the value trimmed. */
|
|
2
|
+
export interface AuthorizationHeader {
|
|
3
|
+
scheme: string;
|
|
4
|
+
value: string;
|
|
5
|
+
}
|
|
6
|
+
/** How an access token is recognised on the wire and shown back to a person. */
|
|
7
|
+
export interface TokenFormatHelper {
|
|
8
|
+
/**
|
|
9
|
+
* Split an `Authorization` header into its scheme and its value.
|
|
10
|
+
*
|
|
11
|
+
* Deliberately NOT `extractAuthToken` from `@owlmeans/auth-common`: that compares the prefix
|
|
12
|
+
* against `type.toUpperCase()`, so it matches `AUTH-TOKEN` and can never match `Bearer` — the
|
|
13
|
+
* exact spelling every third-party client sends. The scheme comes back lower-cased so a caller
|
|
14
|
+
* compares once.
|
|
15
|
+
*/
|
|
16
|
+
parseAuthorizationHeader: (header: string | string[] | undefined) => AuthorizationHeader | null;
|
|
17
|
+
/** Whether a value looks like an access token this deployment issued. */
|
|
18
|
+
isAccessToken: (value: string | null | undefined, prefix: string) => boolean;
|
|
19
|
+
/**
|
|
20
|
+
* The half of a token that may be shown again.
|
|
21
|
+
*
|
|
22
|
+
* Prefix plus the first characters of the secret. Enough to tell two tokens apart in a list, and
|
|
23
|
+
* far short of anything that could be replayed.
|
|
24
|
+
*/
|
|
25
|
+
displayOf: (token: string, prefix: string) => string;
|
|
26
|
+
}
|
|
27
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/format/types.ts"],"names":[],"mappings":"AAAA,wFAAwF;AACxF,MAAM,WAAW,mBAAmB;IAClC,MAAM,EAAE,MAAM,CAAA;IACd,KAAK,EAAE,MAAM,CAAA;CACd;AAED,gFAAgF;AAChF,MAAM,WAAW,iBAAiB;IAChC;;;;;;;OAOG;IACH,wBAAwB,EAAE,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,SAAS,KAAK,mBAAmB,GAAG,IAAI,CAAA;IAC/F,yEAAyE;IACzE,aAAa,EAAE,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EAAE,MAAM,EAAE,MAAM,KAAK,OAAO,CAAA;IAC5E;;;;;OAKG;IACH,SAAS,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,KAAK,MAAM,CAAA;CACrD"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/format/types.ts"],"names":[],"mappings":""}
|
package/build/format.d.ts
CHANGED
|
@@ -1,22 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
* Deliberately NOT `extractAuthToken` from `@owlmeans/auth-common`: that compares the prefix
|
|
5
|
-
* against `type.toUpperCase()`, so it matches `AUTH-TOKEN` and can never match `Bearer` — the
|
|
6
|
-
* exact spelling every third-party client sends. The scheme comes back lower-cased so a caller
|
|
7
|
-
* compares once.
|
|
8
|
-
*/
|
|
9
|
-
export declare const parseAuthorizationHeader: (header: string | string[] | undefined) => {
|
|
10
|
-
scheme: string;
|
|
11
|
-
value: string;
|
|
12
|
-
} | null;
|
|
13
|
-
/** Whether a value looks like an access token this deployment issued. */
|
|
14
|
-
export declare const isAccessToken: (value: string | null | undefined, prefix: string) => boolean;
|
|
15
|
-
/**
|
|
16
|
-
* The half of a token that may be shown again.
|
|
17
|
-
*
|
|
18
|
-
* Prefix plus the first characters of the secret. Enough to tell two tokens apart in a list, and
|
|
19
|
-
* far short of anything that could be replayed.
|
|
20
|
-
*/
|
|
21
|
-
export declare const displayOf: (token: string, prefix: string) => string;
|
|
1
|
+
import type { TokenFormatHelper } from './format/types.js';
|
|
2
|
+
export declare const createTokenFormatHelper: () => TokenFormatHelper;
|
|
3
|
+
export declare const tokenFormatHelper: TokenFormatHelper;
|
|
22
4
|
//# sourceMappingURL=format.d.ts.map
|
package/build/format.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"format.d.ts","sourceRoot":"","sources":["../src/format.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"format.d.ts","sourceRoot":"","sources":["../src/format.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAuB,iBAAiB,EAAE,MAAM,mBAAmB,CAAA;AAE/E,eAAO,MAAM,uBAAuB,QAAO,iBA4B1C,CAAA;AAED,eAAO,MAAM,iBAAiB,mBAA4B,CAAA"}
|
package/build/format.js
CHANGED
|
@@ -1,36 +1,25 @@
|
|
|
1
1
|
import { AUTH_TOKEN_DISPLAY_LENGTH } from './consts.js';
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
return {
|
|
23
|
-
};
|
|
24
|
-
/** Whether a value looks like an access token this deployment issued. */
|
|
25
|
-
export const isAccessToken = (value, prefix) => value != null && value.length > prefix.length && value.startsWith(prefix);
|
|
26
|
-
/**
|
|
27
|
-
* The half of a token that may be shown again.
|
|
28
|
-
*
|
|
29
|
-
* Prefix plus the first characters of the secret. Enough to tell two tokens apart in a list, and
|
|
30
|
-
* far short of anything that could be replayed.
|
|
31
|
-
*/
|
|
32
|
-
export const displayOf = (token, prefix) => {
|
|
33
|
-
const secret = token.startsWith(prefix) ? token.slice(prefix.length) : token;
|
|
34
|
-
return `${prefix}${secret.slice(0, AUTH_TOKEN_DISPLAY_LENGTH)}`;
|
|
2
|
+
export const createTokenFormatHelper = () => {
|
|
3
|
+
const parseAuthorizationHeader = (header) => {
|
|
4
|
+
const raw = Array.isArray(header) ? header[0] : header;
|
|
5
|
+
if (raw == null)
|
|
6
|
+
return null;
|
|
7
|
+
const trimmed = raw.trim();
|
|
8
|
+
const space = trimmed.indexOf(' ');
|
|
9
|
+
if (space < 1)
|
|
10
|
+
return null;
|
|
11
|
+
const scheme = trimmed.slice(0, space).toLowerCase();
|
|
12
|
+
const value = trimmed.slice(space + 1).trim();
|
|
13
|
+
if (value.length < 1)
|
|
14
|
+
return null;
|
|
15
|
+
return { scheme, value };
|
|
16
|
+
};
|
|
17
|
+
const isAccessToken = (value, prefix) => value != null && value.length > prefix.length && value.startsWith(prefix);
|
|
18
|
+
const displayOf = (token, prefix) => {
|
|
19
|
+
const secret = token.startsWith(prefix) ? token.slice(prefix.length) : token;
|
|
20
|
+
return `${prefix}${secret.slice(0, AUTH_TOKEN_DISPLAY_LENGTH)}`;
|
|
21
|
+
};
|
|
22
|
+
return { parseAuthorizationHeader, isAccessToken, displayOf };
|
|
35
23
|
};
|
|
24
|
+
export const tokenFormatHelper = createTokenFormatHelper();
|
|
36
25
|
//# sourceMappingURL=format.js.map
|
package/build/format.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"format.js","sourceRoot":"","sources":["../src/format.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,yBAAyB,EAAE,MAAM,aAAa,CAAA;
|
|
1
|
+
{"version":3,"file":"format.js","sourceRoot":"","sources":["../src/format.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,yBAAyB,EAAE,MAAM,aAAa,CAAA;AAGvD,MAAM,CAAC,MAAM,uBAAuB,GAAG,GAAsB,EAAE;IAC7D,MAAM,wBAAwB,GAAG,CAC/B,MAAqC,EACT,EAAE;QAC9B,MAAM,GAAG,GAAG,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,MAAM,CAAA;QACtD,IAAI,GAAG,IAAI,IAAI;YAAE,OAAO,IAAI,CAAA;QAE5B,MAAM,OAAO,GAAG,GAAG,CAAC,IAAI,EAAE,CAAA;QAC1B,MAAM,KAAK,GAAG,OAAO,CAAC,OAAO,CAAC,GAAG,CAAC,CAAA;QAClC,IAAI,KAAK,GAAG,CAAC;YAAE,OAAO,IAAI,CAAA;QAE1B,MAAM,MAAM,GAAG,OAAO,CAAC,KAAK,CAAC,CAAC,EAAE,KAAK,CAAC,CAAC,WAAW,EAAE,CAAA;QACpD,MAAM,KAAK,GAAG,OAAO,CAAC,KAAK,CAAC,KAAK,GAAG,CAAC,CAAC,CAAC,IAAI,EAAE,CAAA;QAC7C,IAAI,KAAK,CAAC,MAAM,GAAG,CAAC;YAAE,OAAO,IAAI,CAAA;QAEjC,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,CAAA;IAC1B,CAAC,CAAA;IAED,MAAM,aAAa,GAAG,CAAC,KAAgC,EAAE,MAAc,EAAW,EAAE,CAClF,KAAK,IAAI,IAAI,IAAI,KAAK,CAAC,MAAM,GAAG,MAAM,CAAC,MAAM,IAAI,KAAK,CAAC,UAAU,CAAC,MAAM,CAAC,CAAA;IAE3E,MAAM,SAAS,GAAG,CAAC,KAAa,EAAE,MAAc,EAAU,EAAE;QAC1D,MAAM,MAAM,GAAG,KAAK,CAAC,UAAU,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,KAAK,CAAA;QAE5E,OAAO,GAAG,MAAM,GAAG,MAAM,CAAC,KAAK,CAAC,CAAC,EAAE,yBAAyB,CAAC,EAAE,CAAA;IACjE,CAAC,CAAA;IAED,OAAO,EAAE,wBAAwB,EAAE,aAAa,EAAE,SAAS,EAAE,CAAA;AAC/D,CAAC,CAAA;AAED,MAAM,CAAC,MAAM,iBAAiB,GAAG,uBAAuB,EAAE,CAAA"}
|
package/build/index.d.ts
CHANGED
package/build/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,aAAa,CAAA;AAC3B,cAAc,cAAc,CAAA;AAC5B,cAAc,kBAAkB,CAAA;AAChC,cAAc,aAAa,CAAA;AAC3B,cAAc,cAAc,CAAA;AAC5B,mBAAmB,YAAY,CAAA"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,aAAa,CAAA;AAC3B,cAAc,cAAc,CAAA;AAC5B,cAAc,kBAAkB,CAAA;AAChC,cAAc,aAAa,CAAA;AAC3B,cAAc,cAAc,CAAA;AAC5B,mBAAmB,YAAY,CAAA;AAC/B,mBAAmB,mBAAmB,CAAA"}
|
package/build/types.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { AuthRole } from '@owlmeans/auth';
|
|
2
2
|
import type { ResourceRecord } from '@owlmeans/resource';
|
|
3
3
|
import type { EntrypointProtocol, OpenRequest, OpenValue } from '@owlmeans/entrypoint';
|
|
4
|
+
import type { RouteParent } from '@owlmeans/route';
|
|
4
5
|
/**
|
|
5
6
|
* One long-lived access token, as stored.
|
|
6
7
|
*
|
|
@@ -30,9 +31,22 @@ export interface AccessTokenRecord extends ResourceRecord {
|
|
|
30
31
|
expiresAt?: Date;
|
|
31
32
|
/** Set once and never unset. A revoked token is kept so its display name still resolves. */
|
|
32
33
|
revokedAt?: Date;
|
|
34
|
+
/**
|
|
35
|
+
* Resources this token was issued FOR, when it was issued through an OAuth grant
|
|
36
|
+
* (`@owlmeans/server-oauth`).
|
|
37
|
+
*
|
|
38
|
+
* Absent for every token minted by hand through `createAccessToken` — those are admitted
|
|
39
|
+
* everywhere the caller's scopes reach, exactly as before this field existed. A non-empty
|
|
40
|
+
* audience narrows that: a guard configured with `resources` admits the token only when the
|
|
41
|
+
* two lists intersect, so a token minted for the `/mcp` connector cannot also drive the
|
|
42
|
+
* ordinary REST API, and vice versa (RFC 8707's audience-restriction, applied at OUR guard
|
|
43
|
+
* rather than by decoding a JWT audience claim, because this token is an opaque secret).
|
|
44
|
+
*/
|
|
45
|
+
audience?: string[];
|
|
33
46
|
}
|
|
34
47
|
/** What a caller may see. The hash never leaves the server. */
|
|
35
|
-
export
|
|
48
|
+
export interface AccessTokenView extends Omit<AccessTokenRecord, 'hash'> {
|
|
49
|
+
}
|
|
36
50
|
export interface CreateAccessToken {
|
|
37
51
|
name: string;
|
|
38
52
|
/** A subset of the caller's own scopes. Defaults to all of them. */
|
|
@@ -61,10 +75,18 @@ export interface AccessTokenParams {
|
|
|
61
75
|
export interface TokenCarrierOptions {
|
|
62
76
|
token: string | (() => string | Promise<string>);
|
|
63
77
|
scheme?: 'auth-token' | 'bearer';
|
|
78
|
+
/**
|
|
79
|
+
* Called when the platform rejects a request that presented this credential — `@owlmeans/api`
|
|
80
|
+
* reaches for `AuthService.update(undefined)` on such a 401, and a carrier with none of that
|
|
81
|
+
* machinery answers with a no-op unless this is given. A credential holder wires it to whatever
|
|
82
|
+
* it does about a dead token: sign in again if it minted the token itself, or report the
|
|
83
|
+
* problem rather than silently trying another identity if an operator supplied it by hand.
|
|
84
|
+
*/
|
|
85
|
+
onRejected?: () => void | Promise<void>;
|
|
64
86
|
}
|
|
65
87
|
export interface AuthTokenEntrypointOptions {
|
|
66
|
-
/** The entrypoint the token routes hang under. */
|
|
67
|
-
parent?:
|
|
88
|
+
/** The entrypoint the token routes hang under. A parent protocol is preferred; strings remain adapter input. */
|
|
89
|
+
parent?: RouteParent;
|
|
68
90
|
/** Path of the token base, relative to the parent. Defaults to `/tokens`. */
|
|
69
91
|
path?: string;
|
|
70
92
|
/** The guard the base carries when it has no parent to inherit one from. */
|
package/build/types.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAA;AAC9C,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAA;AACxD,OAAO,KAAK,EAAE,kBAAkB,EAAE,WAAW,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAA;
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAA;AAC9C,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAA;AACxD,OAAO,KAAK,EAAE,kBAAkB,EAAE,WAAW,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAA;AACtF,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAA;AAElD;;;;;;;;GAQG;AACH,MAAM,WAAW,iBAAkB,SAAQ,cAAc;IACvD,EAAE,CAAC,EAAE,MAAM,CAAA;IACX,6FAA6F;IAC7F,IAAI,EAAE,MAAM,CAAA;IACZ,4FAA4F;IAC5F,OAAO,EAAE,MAAM,CAAA;IACf,gCAAgC;IAChC,IAAI,EAAE,MAAM,CAAA;IACZ,MAAM,EAAE,MAAM,CAAA;IACd,SAAS,EAAE,MAAM,CAAA;IACjB,QAAQ,EAAE,MAAM,CAAA;IAChB,MAAM,EAAE,MAAM,EAAE,CAAA;IAChB,IAAI,EAAE,QAAQ,CAAA;IACd,SAAS,EAAE,IAAI,CAAA;IACf,SAAS,CAAC,EAAE,IAAI,CAAA;IAChB,mFAAmF;IACnF,UAAU,CAAC,EAAE,IAAI,CAAA;IACjB,SAAS,CAAC,EAAE,IAAI,CAAA;IAChB,4FAA4F;IAC5F,SAAS,CAAC,EAAE,IAAI,CAAA;IAChB;;;;;;;;;;OAUG;IACH,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAA;CACpB;AAED,+DAA+D;AAC/D,MAAM,WAAW,eAAgB,SAAQ,IAAI,CAAC,iBAAiB,EAAE,MAAM,CAAC;CAAG;AAE3E,MAAM,WAAW,iBAAiB;IAChC,IAAI,EAAE,MAAM,CAAA;IACZ,oEAAoE;IACpE,MAAM,CAAC,EAAE,MAAM,EAAE,CAAA;IACjB,+EAA+E;IAC/E,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAED,4EAA4E;AAC5E,MAAM,WAAW,iBAAiB;IAChC,KAAK,EAAE,MAAM,CAAA;IACb,MAAM,EAAE,eAAe,CAAA;CACxB;AAED,MAAM,WAAW,eAAe;IAC9B,KAAK,EAAE,eAAe,EAAE,CAAA;CACzB;AAED,MAAM,WAAW,iBAAiB;IAChC,EAAE,EAAE,MAAM,CAAA;CACX;AAED;;;;;;GAMG;AACH,MAAM,WAAW,mBAAmB;IAClC,KAAK,EAAE,MAAM,GAAG,CAAC,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC,CAAA;IAChD,MAAM,CAAC,EAAE,YAAY,GAAG,QAAQ,CAAA;IAChC;;;;;;OAMG;IACH,UAAU,CAAC,EAAE,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;CACxC;AAED,MAAM,WAAW,0BAA0B;IACzC,gHAAgH;IAChH,MAAM,CAAC,EAAE,WAAW,CAAA;IACpB,6EAA6E;IAC7E,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,4EAA4E;IAC5E,KAAK,CAAC,EAAE,MAAM,CAAA;CACf;AAED,iEAAiE;AACjE,MAAM,WAAW,oBAAoB;IACnC,IAAI,EAAE,kBAAkB,CAAC,WAAW,EAAE,SAAS,CAAC,CAAA;IAChD,IAAI,EAAE,kBAAkB,CAAC,WAAW,EAAE,eAAe,CAAC,CAAA;IACtD,MAAM,EAAE,kBAAkB,CAAC;QAAE,IAAI,EAAE,iBAAiB,CAAA;KAAE,EAAE,iBAAiB,CAAC,CAAA;IAC1E,MAAM,EAAE,kBAAkB,CAAC;QAAE,MAAM,EAAE,iBAAiB,CAAA;KAAE,EAAE;QAAE,EAAE,EAAE,MAAM,CAAA;KAAE,CAAC,CAAA;CAC1E"}
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@owlmeans/auth-token",
|
|
3
|
-
"version": "0.1.18
|
|
3
|
+
"version": "0.1.18",
|
|
4
4
|
"license": "MIT",
|
|
5
|
-
"description": "Long-lived access tokens (API keys) on the OwlMeans auth rails
|
|
5
|
+
"description": "Long-lived access tokens (API keys) on the OwlMeans auth rails \u2014 the record shape, the token format, the management entrypoints and the client-side carrier guard.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"scripts": {
|
|
8
8
|
"build": "tsc -b",
|
|
@@ -23,16 +23,16 @@
|
|
|
23
23
|
}
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
|
-
"@owlmeans/auth": "^0.1.18
|
|
27
|
-
"@owlmeans/context": "^0.1.18
|
|
28
|
-
"@owlmeans/entrypoint": "^0.1.18
|
|
29
|
-
"@owlmeans/resource": "^0.1.18
|
|
30
|
-
"@owlmeans/route": "^0.1.18
|
|
26
|
+
"@owlmeans/auth": "^0.1.18",
|
|
27
|
+
"@owlmeans/context": "^0.1.18",
|
|
28
|
+
"@owlmeans/entrypoint": "^0.1.18",
|
|
29
|
+
"@owlmeans/resource": "^0.1.18",
|
|
30
|
+
"@owlmeans/route": "^0.1.18"
|
|
31
31
|
},
|
|
32
32
|
"devDependencies": {
|
|
33
|
-
"@owlmeans/dep-config": "
|
|
34
|
-
"@types/bun": "^1.4.
|
|
35
|
-
"ajv": "^8.
|
|
33
|
+
"@owlmeans/dep-config": "^0.1.18",
|
|
34
|
+
"@types/bun": "^1.4.2",
|
|
35
|
+
"ajv": "^8.20.0",
|
|
36
36
|
"nodemon": "^3.1.14",
|
|
37
37
|
"typescript": "^7.0.2"
|
|
38
38
|
},
|
package/src/carrier.ts
CHANGED
|
@@ -36,5 +36,13 @@ export const makeTokenCarrierGuard = (alias: string, opts: TokenCarrierOptions):
|
|
|
36
36
|
},
|
|
37
37
|
}, service => async () => { service.initialized = true })
|
|
38
38
|
|
|
39
|
+
// `@owlmeans/api` calls `context.service<AuthService>(alias).update(undefined)` on a 401 for a
|
|
40
|
+
// request that presented this guard's bearer — an ordinary browser session clears itself there,
|
|
41
|
+
// and a carrier with no `update` at all made that call throw a bare `TypeError` instead of the
|
|
42
|
+
// auth failure it was already reporting. There is no session to clear here, so this is a no-op
|
|
43
|
+
// unless the caller asked to hear about it through `onRejected`.
|
|
44
|
+
;(service as GuardService & { update: (token: string | undefined) => Promise<void> }).update =
|
|
45
|
+
async () => { await opts.onRejected?.() }
|
|
46
|
+
|
|
39
47
|
return service
|
|
40
48
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** An `Authorization` header taken apart: the scheme lower-cased, the value trimmed. */
|
|
2
|
+
export interface AuthorizationHeader {
|
|
3
|
+
scheme: string
|
|
4
|
+
value: string
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/** How an access token is recognised on the wire and shown back to a person. */
|
|
8
|
+
export interface TokenFormatHelper {
|
|
9
|
+
/**
|
|
10
|
+
* Split an `Authorization` header into its scheme and its value.
|
|
11
|
+
*
|
|
12
|
+
* Deliberately NOT `extractAuthToken` from `@owlmeans/auth-common`: that compares the prefix
|
|
13
|
+
* against `type.toUpperCase()`, so it matches `AUTH-TOKEN` and can never match `Bearer` — the
|
|
14
|
+
* exact spelling every third-party client sends. The scheme comes back lower-cased so a caller
|
|
15
|
+
* compares once.
|
|
16
|
+
*/
|
|
17
|
+
parseAuthorizationHeader: (header: string | string[] | undefined) => AuthorizationHeader | null
|
|
18
|
+
/** Whether a value looks like an access token this deployment issued. */
|
|
19
|
+
isAccessToken: (value: string | null | undefined, prefix: string) => boolean
|
|
20
|
+
/**
|
|
21
|
+
* The half of a token that may be shown again.
|
|
22
|
+
*
|
|
23
|
+
* Prefix plus the first characters of the secret. Enough to tell two tokens apart in a list, and
|
|
24
|
+
* far short of anything that could be replayed.
|
|
25
|
+
*/
|
|
26
|
+
displayOf: (token: string, prefix: string) => string
|
|
27
|
+
}
|
package/src/format.ts
CHANGED
|
@@ -1,42 +1,34 @@
|
|
|
1
1
|
import { AUTH_TOKEN_DISPLAY_LENGTH } from './consts.js'
|
|
2
|
+
import type { AuthorizationHeader, TokenFormatHelper } from './format/types.js'
|
|
2
3
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
const trimmed = raw.trim()
|
|
18
|
-
const space = trimmed.indexOf(' ')
|
|
19
|
-
if (space < 1) return null
|
|
20
|
-
|
|
21
|
-
const scheme = trimmed.slice(0, space).toLowerCase()
|
|
22
|
-
const value = trimmed.slice(space + 1).trim()
|
|
23
|
-
if (value.length < 1) return null
|
|
24
|
-
|
|
25
|
-
return { scheme, value }
|
|
26
|
-
}
|
|
4
|
+
export const createTokenFormatHelper = (): TokenFormatHelper => {
|
|
5
|
+
const parseAuthorizationHeader = (
|
|
6
|
+
header: string | string[] | undefined
|
|
7
|
+
): AuthorizationHeader | null => {
|
|
8
|
+
const raw = Array.isArray(header) ? header[0] : header
|
|
9
|
+
if (raw == null) return null
|
|
10
|
+
|
|
11
|
+
const trimmed = raw.trim()
|
|
12
|
+
const space = trimmed.indexOf(' ')
|
|
13
|
+
if (space < 1) return null
|
|
14
|
+
|
|
15
|
+
const scheme = trimmed.slice(0, space).toLowerCase()
|
|
16
|
+
const value = trimmed.slice(space + 1).trim()
|
|
17
|
+
if (value.length < 1) return null
|
|
27
18
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
value != null && value.length > prefix.length && value.startsWith(prefix)
|
|
19
|
+
return { scheme, value }
|
|
20
|
+
}
|
|
31
21
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
*
|
|
35
|
-
* Prefix plus the first characters of the secret. Enough to tell two tokens apart in a list, and
|
|
36
|
-
* far short of anything that could be replayed.
|
|
37
|
-
*/
|
|
38
|
-
export const displayOf = (token: string, prefix: string): string => {
|
|
39
|
-
const secret = token.startsWith(prefix) ? token.slice(prefix.length) : token
|
|
22
|
+
const isAccessToken = (value: string | null | undefined, prefix: string): boolean =>
|
|
23
|
+
value != null && value.length > prefix.length && value.startsWith(prefix)
|
|
40
24
|
|
|
41
|
-
|
|
25
|
+
const displayOf = (token: string, prefix: string): string => {
|
|
26
|
+
const secret = token.startsWith(prefix) ? token.slice(prefix.length) : token
|
|
27
|
+
|
|
28
|
+
return `${prefix}${secret.slice(0, AUTH_TOKEN_DISPLAY_LENGTH)}`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return { parseAuthorizationHeader, isAccessToken, displayOf }
|
|
42
32
|
}
|
|
33
|
+
|
|
34
|
+
export const tokenFormatHelper = createTokenFormatHelper()
|
package/src/index.ts
CHANGED
package/src/types.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { AuthRole } from '@owlmeans/auth'
|
|
2
2
|
import type { ResourceRecord } from '@owlmeans/resource'
|
|
3
3
|
import type { EntrypointProtocol, OpenRequest, OpenValue } from '@owlmeans/entrypoint'
|
|
4
|
+
import type { RouteParent } from '@owlmeans/route'
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* One long-lived access token, as stored.
|
|
@@ -31,10 +32,22 @@ export interface AccessTokenRecord extends ResourceRecord {
|
|
|
31
32
|
expiresAt?: Date
|
|
32
33
|
/** Set once and never unset. A revoked token is kept so its display name still resolves. */
|
|
33
34
|
revokedAt?: Date
|
|
35
|
+
/**
|
|
36
|
+
* Resources this token was issued FOR, when it was issued through an OAuth grant
|
|
37
|
+
* (`@owlmeans/server-oauth`).
|
|
38
|
+
*
|
|
39
|
+
* Absent for every token minted by hand through `createAccessToken` — those are admitted
|
|
40
|
+
* everywhere the caller's scopes reach, exactly as before this field existed. A non-empty
|
|
41
|
+
* audience narrows that: a guard configured with `resources` admits the token only when the
|
|
42
|
+
* two lists intersect, so a token minted for the `/mcp` connector cannot also drive the
|
|
43
|
+
* ordinary REST API, and vice versa (RFC 8707's audience-restriction, applied at OUR guard
|
|
44
|
+
* rather than by decoding a JWT audience claim, because this token is an opaque secret).
|
|
45
|
+
*/
|
|
46
|
+
audience?: string[]
|
|
34
47
|
}
|
|
35
48
|
|
|
36
49
|
/** What a caller may see. The hash never leaves the server. */
|
|
37
|
-
export
|
|
50
|
+
export interface AccessTokenView extends Omit<AccessTokenRecord, 'hash'> {}
|
|
38
51
|
|
|
39
52
|
export interface CreateAccessToken {
|
|
40
53
|
name: string
|
|
@@ -68,11 +81,19 @@ export interface AccessTokenParams {
|
|
|
68
81
|
export interface TokenCarrierOptions {
|
|
69
82
|
token: string | (() => string | Promise<string>)
|
|
70
83
|
scheme?: 'auth-token' | 'bearer'
|
|
84
|
+
/**
|
|
85
|
+
* Called when the platform rejects a request that presented this credential — `@owlmeans/api`
|
|
86
|
+
* reaches for `AuthService.update(undefined)` on such a 401, and a carrier with none of that
|
|
87
|
+
* machinery answers with a no-op unless this is given. A credential holder wires it to whatever
|
|
88
|
+
* it does about a dead token: sign in again if it minted the token itself, or report the
|
|
89
|
+
* problem rather than silently trying another identity if an operator supplied it by hand.
|
|
90
|
+
*/
|
|
91
|
+
onRejected?: () => void | Promise<void>
|
|
71
92
|
}
|
|
72
93
|
|
|
73
94
|
export interface AuthTokenEntrypointOptions {
|
|
74
|
-
/** The entrypoint the token routes hang under. */
|
|
75
|
-
parent?:
|
|
95
|
+
/** The entrypoint the token routes hang under. A parent protocol is preferred; strings remain adapter input. */
|
|
96
|
+
parent?: RouteParent
|
|
76
97
|
/** Path of the token base, relative to the parent. Defaults to `/tokens`. */
|
|
77
98
|
path?: string
|
|
78
99
|
/** The guard the base carries when it has no parent to inherit one from. */
|
package/tests/carrier.spec.ts
CHANGED
|
@@ -46,4 +46,24 @@ describe('@owlmeans/auth-token — the client carrier guard', () => {
|
|
|
46
46
|
expect(await guard.authenticated()).toBeNull()
|
|
47
47
|
expect(await guard.match({} as any, {} as any)).toBe(false)
|
|
48
48
|
})
|
|
49
|
+
|
|
50
|
+
test('answers update() rather than throwing — the call `@owlmeans/api` makes on a 401', async () => {
|
|
51
|
+
const guard = makeTokenCarrierGuard('carrier', { token: 'owl_secret' }) as GuardService & {
|
|
52
|
+
update: (token: string | undefined) => Promise<void>
|
|
53
|
+
}
|
|
54
|
+
await contextWith(guard)
|
|
55
|
+
|
|
56
|
+
await expect(guard.update(undefined)).resolves.toBeUndefined()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test('update() calls onRejected, so a credential holder can act on a dead token', async () => {
|
|
60
|
+
let called = false
|
|
61
|
+
const guard = makeTokenCarrierGuard('carrier', {
|
|
62
|
+
token: 'owl_secret', onRejected: () => { called = true },
|
|
63
|
+
}) as GuardService & { update: (token: string | undefined) => Promise<void> }
|
|
64
|
+
await contextWith(guard)
|
|
65
|
+
|
|
66
|
+
await guard.update(undefined)
|
|
67
|
+
expect(called).toBe(true)
|
|
68
|
+
})
|
|
49
69
|
})
|
package/tests/format.spec.ts
CHANGED
|
@@ -1,43 +1,43 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test'
|
|
2
|
-
import {
|
|
2
|
+
import { tokenFormatHelper } from '../src/format.js'
|
|
3
3
|
import { AUTH_TOKEN_DEFAULT_PREFIX } from '../src/consts.js'
|
|
4
4
|
|
|
5
5
|
describe('@owlmeans/auth-token — Authorization parsing', () => {
|
|
6
6
|
test('lower-cases the scheme so Bearer and BEARER are one answer', () => {
|
|
7
|
-
expect(parseAuthorizationHeader('Bearer owl_abc')).toEqual({ scheme: 'bearer', value: 'owl_abc' })
|
|
8
|
-
expect(parseAuthorizationHeader('BEARER owl_abc')?.scheme).toBe('bearer')
|
|
9
|
-
expect(parseAuthorizationHeader('AUTH-TOKEN owl_abc')?.scheme).toBe('auth-token')
|
|
7
|
+
expect(tokenFormatHelper.parseAuthorizationHeader('Bearer owl_abc')).toEqual({ scheme: 'bearer', value: 'owl_abc' })
|
|
8
|
+
expect(tokenFormatHelper.parseAuthorizationHeader('BEARER owl_abc')?.scheme).toBe('bearer')
|
|
9
|
+
expect(tokenFormatHelper.parseAuthorizationHeader('AUTH-TOKEN owl_abc')?.scheme).toBe('auth-token')
|
|
10
10
|
})
|
|
11
11
|
|
|
12
12
|
test('takes the first header when a proxy folded several', () => {
|
|
13
|
-
expect(parseAuthorizationHeader(['Bearer one', 'Bearer two'])?.value).toBe('one')
|
|
13
|
+
expect(tokenFormatHelper.parseAuthorizationHeader(['Bearer one', 'Bearer two'])?.value).toBe('one')
|
|
14
14
|
})
|
|
15
15
|
|
|
16
16
|
test('answers null for anything that is not a scheme and a value', () => {
|
|
17
|
-
expect(parseAuthorizationHeader(undefined)).toBeNull()
|
|
18
|
-
expect(parseAuthorizationHeader('')).toBeNull()
|
|
19
|
-
expect(parseAuthorizationHeader('Bearer')).toBeNull()
|
|
20
|
-
expect(parseAuthorizationHeader('Bearer ')).toBeNull()
|
|
21
|
-
expect(parseAuthorizationHeader(' leadingspace')).toBeNull()
|
|
17
|
+
expect(tokenFormatHelper.parseAuthorizationHeader(undefined)).toBeNull()
|
|
18
|
+
expect(tokenFormatHelper.parseAuthorizationHeader('')).toBeNull()
|
|
19
|
+
expect(tokenFormatHelper.parseAuthorizationHeader('Bearer')).toBeNull()
|
|
20
|
+
expect(tokenFormatHelper.parseAuthorizationHeader('Bearer ')).toBeNull()
|
|
21
|
+
expect(tokenFormatHelper.parseAuthorizationHeader(' leadingspace')).toBeNull()
|
|
22
22
|
})
|
|
23
23
|
|
|
24
24
|
test('keeps a value that itself contains spaces', () => {
|
|
25
|
-
expect(parseAuthorizationHeader('Bearer a b c')?.value).toBe('a b c')
|
|
25
|
+
expect(tokenFormatHelper.parseAuthorizationHeader('Bearer a b c')?.value).toBe('a b c')
|
|
26
26
|
})
|
|
27
27
|
})
|
|
28
28
|
|
|
29
29
|
describe('@owlmeans/auth-token — token shape', () => {
|
|
30
30
|
test('claims only a value carrying the prefix', () => {
|
|
31
|
-
expect(isAccessToken('owl_abcdef', AUTH_TOKEN_DEFAULT_PREFIX)).toBe(true)
|
|
32
|
-
expect(isAccessToken('vib_abcdef', AUTH_TOKEN_DEFAULT_PREFIX)).toBe(false)
|
|
33
|
-
expect(isAccessToken('abcdef', AUTH_TOKEN_DEFAULT_PREFIX)).toBe(false)
|
|
31
|
+
expect(tokenFormatHelper.isAccessToken('owl_abcdef', AUTH_TOKEN_DEFAULT_PREFIX)).toBe(true)
|
|
32
|
+
expect(tokenFormatHelper.isAccessToken('vib_abcdef', AUTH_TOKEN_DEFAULT_PREFIX)).toBe(false)
|
|
33
|
+
expect(tokenFormatHelper.isAccessToken('abcdef', AUTH_TOKEN_DEFAULT_PREFIX)).toBe(false)
|
|
34
34
|
// The prefix alone is not a token — there is no secret behind it.
|
|
35
|
-
expect(isAccessToken(AUTH_TOKEN_DEFAULT_PREFIX, AUTH_TOKEN_DEFAULT_PREFIX)).toBe(false)
|
|
36
|
-
expect(isAccessToken(null, AUTH_TOKEN_DEFAULT_PREFIX)).toBe(false)
|
|
35
|
+
expect(tokenFormatHelper.isAccessToken(AUTH_TOKEN_DEFAULT_PREFIX, AUTH_TOKEN_DEFAULT_PREFIX)).toBe(false)
|
|
36
|
+
expect(tokenFormatHelper.isAccessToken(null, AUTH_TOKEN_DEFAULT_PREFIX)).toBe(false)
|
|
37
37
|
})
|
|
38
38
|
|
|
39
39
|
test('the display form keeps the prefix and eight characters of the secret', () => {
|
|
40
|
-
const display = displayOf('owl_ABCDEFGHIJKLMNOP', 'owl_')
|
|
40
|
+
const display = tokenFormatHelper.displayOf('owl_ABCDEFGHIJKLMNOP', 'owl_')
|
|
41
41
|
expect(display).toBe('owl_ABCDEFGH')
|
|
42
42
|
// Short enough to be useless, long enough to tell two of a person's tokens apart.
|
|
43
43
|
expect(display.length).toBeLessThan('owl_ABCDEFGHIJKLMNOP'.length)
|