@owlmeans/server-auth-token 0.1.18-rc.33 → 0.1.18-rc.34

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 CHANGED
@@ -1,3 +1,179 @@
1
+ # @owlmeans/server-auth-token
2
+
3
+ The server half of long-lived access tokens: a MongoDB token store, a guard that verifies a
4
+ presented token and turns it into the same `Auth` payload a browser session produces, the
5
+ list / create / revoke handlers, a shared issuer for other minting paths (an OAuth token endpoint),
6
+ and a coguard helper that admits a token on every already-guarded route. A backend uses it when API
7
+ clients, CLIs or coding agents should call the same routes a signed-in person does. The token
8
+ format, routes and record types live in `@owlmeans/auth-token`; the browser management panel is
9
+ `@owlmeans/web-auth-token`; issuing tokens through an OAuth grant is `@owlmeans/server-oauth`, which
10
+ calls this package's issuer.
11
+
12
+ ## Installation
13
+
14
+ ```bash
15
+ bun add @owlmeans/server-auth-token@^0.1.18-rc.34
16
+ ```
17
+
18
+ ## Concepts
19
+
20
+ - **Access token** — `<prefix><base58 of 24 random bytes>`; the prefix defaults to `owl_`. The
21
+ plaintext is returned once at creation; the store keeps only its SHA-256 `hash` and a short
22
+ `display` form.
23
+ - **Guard, not authentication** — the guard verifies what arrives and resolves an `Auth` with
24
+ `type: AuthroizationType.AuthToken`; `authenticated()` always returns `null`, nothing is exchanged.
25
+ - **Prefix claim** — the guard matches only an `Authorization` value with the `AUTH-TOKEN` or
26
+ `Bearer` scheme that carries the deployment's prefix, so it coexists with other bearer guards.
27
+ - **Scope intersection** — on every request the token's scopes are intersected with its identity
28
+ profile's; a missing or expired profile refuses the token.
29
+ - **Audience** — a token minted through OAuth carries `audience`; a guard configured with
30
+ `resources` refuses a token whose audience does not intersect them. Without `resources`, every
31
+ token is admitted.
32
+ - **Deny list** — `denyAliases` names routes the guard never claims, so an interactive-only route
33
+ stays a 401 for a token even when the guard is inherited.
34
+
35
+ ## Usage
36
+
37
+ ### Register the store and the guard
38
+
39
+ ```typescript
40
+ import { GUARD_AUTH_TOKEN } from '@owlmeans/auth-token'
41
+ import { AUTH_IDENTITY_DB_ALIAS } from '@owlmeans/server-auth-identity'
42
+ import { appendAuthTokenGuard, appendAuthTokenResources } from '@owlmeans/server-auth-token'
43
+
44
+ appendAuthTokenResources(context, AUTH_IDENTITY_DB_ALIAS) // same dbAlias as the identity resources
45
+ appendAuthTokenGuard(context, GUARD_AUTH_TOKEN, {
46
+ prefix: 'vib_', // also written to cfg.authToken.prefix
47
+ denyAliases: [protocols.tokens.create.alias, protocols.tokens.revoke.alias, checkoutProtocol.alias],
48
+ })
49
+ ```
50
+
51
+ ### Bind the token routes
52
+
53
+ ```typescript
54
+ import { makeAuthTokenEntrypoints } from '@owlmeans/auth-token'
55
+ import { bind } from '@owlmeans/server-entrypoint'
56
+ import { createAccessToken, listAccessTokens, revokeAccessToken } from '@owlmeans/server-auth-token'
57
+
58
+ const tokens = makeAuthTokenEntrypoints({ parent: account.base })
59
+
60
+ context.registerEntrypoints([
61
+ bind(tokens.list, listAccessTokens(tokens.list)),
62
+ bind(tokens.create, createAccessToken(tokens.create)),
63
+ bind(tokens.revoke, revokeAccessToken(tokens.revoke)),
64
+ ])
65
+ ```
66
+
67
+ `revokeAccessToken(protocol, { allowAccessTokens: true })` lets a token revoke the same person's
68
+ tokens (for an agent-facing surface); ownership is still checked and minting stays refused.
69
+
70
+ ### Admit tokens on every guarded route
71
+
72
+ ```typescript
73
+ import { withAuthTokenCoguard } from '@owlmeans/server-auth-token'
74
+
75
+ export const guardedProtocols = withAuthTokenCoguard(appProtocols) // before creating bindings
76
+ ```
77
+
78
+ The coguard is appended after each protocol's existing guards; protocols without a guard are left
79
+ alone, and running it twice changes nothing.
80
+
81
+ ### Restrict a surface to tokens issued for it
82
+
83
+ ```typescript
84
+ appendAuthTokenGuard(context, 'guard:mcp-token', {
85
+ resources: ctx => [mcpResourceUri(ctx)], // app function, resolved per request
86
+ })
87
+ ```
88
+
89
+ ### Mint from another flow
90
+
91
+ ```typescript
92
+ import { accessTokenIssuerOf, refuseTokenAuth } from '@owlmeans/server-auth-token'
93
+
94
+ refuseTokenAuth(req, 'token-mint') // a token may never mint a token
95
+ const issued = await accessTokenIssuerOf(context).issueAccessToken(
96
+ { entityId, userId, profileId, role, scopes: ownScopes },
97
+ { name: 'MCP connector', scopes: ['*'], expiresIn: 3600, audience: [resourceUri] },
98
+ )
99
+ // issued.token is the plaintext, shown once; issued.record is the hash-free view
100
+ ```
101
+
102
+ ## API
103
+
104
+ ### Registration and guard
105
+
106
+ | Symbol | Purpose |
107
+ |---|---|
108
+ | `appendAuthTokenResources(ctx, dbAlias?)` | Registers `makeAccessTokenResource(dbAlias)` |
109
+ | `appendAuthTokenGuard(ctx, alias?, opts?)` | Registers the guard (default alias `GUARD_AUTH_TOKEN`); writes `opts.prefix` into `cfg.authToken.prefix` |
110
+ | `makeAuthTokenGuard(alias?, opts?)` | The `GuardService` |
111
+ | `makeAccessTokenResource(dbAlias?)` | Mongo resource at `AUTH_TOKEN_RESOURCE`, collection `access-token`; indexes `hash` (unique), `owner` (`entityId`, `profileId`), `expiresAt` (sparse) |
112
+ | `withAuthTokenCoguard(tree, guard?)` | Same-shaped protocol tree with the token guard appended to every guarded protocol |
113
+ | `prefixOf(context, opts?)` | Prefix from guard options, then `cfg.authToken.prefix`, then `owl_` |
114
+
115
+ ### Handlers and issuance
116
+
117
+ | Symbol | Purpose |
118
+ |---|---|
119
+ | `listAccessTokens(protocol)` | The caller's tokens in the caller's organization entity, newest first, without hashes |
120
+ | `createAccessToken(protocol)` | Mints a token from the session; refuses token auth; scopes may only narrow |
121
+ | `revokeAccessToken(protocol, opts?)` | Sets `revokedAt` on the caller's own token; idempotent; a foreign id answers like an unknown one |
122
+ | `accessTokenIssuerOf(ctx).issueAccessToken(subject, request)` | The shared minting path; `expiresIn` is clamped to `AUTH_TOKEN_MAX_TTL` |
123
+ | `makeAccessTokenIssuer(ctx)` | Builds an `AccessTokenIssuer` (unmemoized) |
124
+ | `refuseTokenAuth(req, what)` | Throws `AuthForbidden(what)` when `req.auth.type` is an access token |
125
+ | `tokenHashHelper.hashAccessToken(token)` | SHA-256 hex — the stored form |
126
+ | `tokenHashHelper.mintAccessToken(prefix)` | `MintedToken` `{ token, hash, display }` |
127
+ | `createTokenHashHelper()` | Builds a `TokenHashHelper` |
128
+ | `issueAccessToken(ctx, subject, request)`, `hashAccessToken(token)` | Deprecated wrappers |
129
+ | `SERVER_AUTH_TOKEN` | `'server-auth-token'` — module alias for error locations |
130
+
131
+ ### Types
132
+
133
+ | Symbol | Purpose |
134
+ |---|---|
135
+ | `AuthTokenGuardOptions` | `prefix?`, `denyAliases?`, `touchInterval?` (default 5 min), `resourceAlias?`, `profileAlias?`, `resources?` |
136
+ | `AuthTokenConfig` | `ServerConfig` with `authToken?: { prefix? }` |
137
+ | `AuthTokenContext` | `ServerContext<AuthTokenConfig>` |
138
+ | `AccessTokenResource` | `MongoResource<AccessTokenRecord>` |
139
+ | `IssueAccessTokenSubject` | `{ entityId, userId, profileId, role, scopes }` |
140
+ | `IssueAccessTokenRequest` | `CreateAccessToken` (`name`, `scopes?`, `expiresIn?`) plus `audience?` |
141
+ | `AccessTokenIssuer`, `RevokeAccessTokenOptions`, `MintedToken`, `TokenHashHelper` | Supporting types |
142
+
143
+ ## Guard behaviour
144
+
145
+ A presented token is refused when it is unknown, revoked, expired, outside the configured
146
+ `resources`, or when its identity profile is missing, unreadable or expired. On success the payload
147
+ carries the token's **display** form (never the secret), the profile's `role`, the intersected
148
+ scopes, and an `entitySlug` resolved through `ENTITY_RESOLVER` when one is registered. `lastUsedAt`
149
+ is written fire-and-forget at most once per `touchInterval`. Refusals are logged once a minute per
150
+ token and reason, with the record id only.
151
+
152
+ ## Common pitfalls
153
+
154
+ - Passing `prefix` only to a guard you build with `makeAuthTokenGuard` — the minting handler reads the
155
+ prefix from config, so the two disagree and every new token is a 401. Use `appendAuthTokenGuard`.
156
+ - Registering the token store on a different `dbAlias` than the identity resources.
157
+ - Forgetting the deny list — a coguard-admitted token then reaches routes that must stay
158
+ session-only (minting, checkout, starting an OAuth flow). A child route cannot drop an inherited
159
+ guard; the refusal belongs in `denyAliases`.
160
+ - Expecting a requested scope the caller lacks to be dropped — it is refused with
161
+ `AuthForbidden('scope')`.
162
+ - Treating `lastUsedAt` as an access log — it is throttled and best-effort.
163
+ - Decorating protocols with `withAuthTokenCoguard` after bindings were created.
164
+
165
+ The `server-auth-token` skill covers the guard, the store and the deny-list rule in depth; the
166
+ `auth-token` skill covers the format and routes.
167
+
168
+ ## Related packages
169
+
170
+ - [`@owlmeans/auth-token`](../auth-token) — token format, routes, record types, constants
171
+ - [`@owlmeans/web-auth-token`](../web-auth-token) — the browser token management panel
172
+ - [`@owlmeans/server-oauth`](../server-oauth) — OAuth authorization server that mints audience-scoped tokens
173
+ - [`@owlmeans/server-auth-identity`](../server-auth-identity) — the identity profiles a token is bound to
174
+ - [`@owlmeans/mongo-resource`](../mongo-resource) — the token store's backend
175
+ - [`@owlmeans/server-api`](../server-api) — the `handlers()` builder the handlers use
176
+
1
177
  <!-- owlmeans:agent-guidance:start -->
2
178
  ## Agent guidance
3
179
 
@@ -6,7 +182,7 @@ This package ships embedded agent skills under `agent-meta/`. After installing y
6
182
  your project's skill store (`.agents/skills/`):
7
183
 
8
184
  ```sh
9
- npx @owlmeans/agent-skills@^0.1.18-rc.51
185
+ npx @owlmeans/agent-skills@^0.1.18-rc.52
10
186
  ```
11
187
 
12
188
  The embedded files are version-matched to this package release. Do not edit them
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "schemaVersion": 2,
3
3
  "package": "@owlmeans/server-auth-token",
4
- "version": "0.1.18-rc.33",
5
- "generatedAt": "2026-10-06T11:45:57.687Z",
4
+ "version": "0.1.18-rc.34",
5
+ "generatedAt": "2026-10-08T21:27:54.322Z",
6
6
  "canonicalRepo": "https://github.com/owlmeans/common",
7
7
  "entries": [
8
8
  {
@@ -8,7 +8,7 @@ user-invocable: false
8
8
  # @owlmeans/server-auth-token
9
9
 
10
10
  **Layer:** Server
11
- **Install:** `"@owlmeans/server-auth-token": "^0.1.18-rc.33"` in `dependencies`
11
+ **Install:** `"@owlmeans/server-auth-token": "^0.1.18-rc.34"` in `dependencies`
12
12
  **Contracts:** `@owlmeans/auth-token` — the record, the routes, the format helpers
13
13
 
14
14
  ## Key Exports
@@ -22,10 +22,10 @@ user-invocable: false
22
22
  | `prefixOf(context, opts?)` | The deployment's prefix: guard options → config → default |
23
23
  | `tokenHashHelper` — `.hashAccessToken(token)` · `.mintAccessToken(prefix)` | The stored form; one minted `{ token, hash, display }` |
24
24
  | `withAuthTokenCoguard(protocolTree, guard?)` | Return the same-shaped immutable tree with the guard appended to every already-guarded protocol |
25
- | `listAccessTokens` · `createAccessToken` · `revokeAccessToken` | The three handlers |
25
+ | `listAccessTokens(protocol)` · `createAccessToken(protocol)` · `revokeAccessToken(protocol, { allowAccessTokens? })` | The three handlers; only revoke takes an option (`RevokeAccessTokenOptions`) |
26
26
  | `accessTokenIssuerOf(ctx).issueAccessToken(subject, request)` | Mint one token for `{ entityId, userId, profileId, role, scopes }` — what `createAccessToken` does over an HTTP body, and what an OAuth token endpoint calls over a session it verified itself. `request` is `CreateAccessToken` plus `audience?: string[]` |
27
27
  | `refuseTokenAuth(req, what)` | The one "interactive session only" check — throws `AuthForbidden` when `req.auth.type` is an access token |
28
- | `AuthTokenGuardOptions` (`prefix`, `denyAliases`, `touchInterval`, `resourceAlias`, `profileAlias`, `resources`) · `IssueAccessTokenSubject` · `IssueAccessTokenRequest` · `AuthTokenConfig` · `AccessTokenResource` | Types |
28
+ | `AuthTokenGuardOptions` (`prefix`, `denyAliases`, `touchInterval`, `resourceAlias`, `profileAlias`, `resources`) · `IssueAccessTokenSubject` · `IssueAccessTokenRequest` · `RevokeAccessTokenOptions` · `AuthTokenConfig` · `AccessTokenResource` | Types |
29
29
 
30
30
  ## Wiring
31
31
 
@@ -94,14 +94,24 @@ timestamp is never a reason to fail a request, and awaiting it would put a datab
94
94
  critical path of every authenticated call. It answers one question — "is this token still in use?" —
95
95
  and is not an access log.
96
96
 
97
- ## A token may never mint or revoke a token
97
+ ## A token may never mint a token; it revokes one only where the deployment opts in
98
98
 
99
99
  Minting is the one operation that turns a stolen credential into a permanent one: a token that can
100
- create tokens survives the revocation of the token that leaked. `createAccessToken`, `revokeAccessToken` and any other minting path (an OAuth consent approval calls
101
- `refuseTokenAuth` before it snapshots the subject) refuse a request whose `auth.type` is `AuthroizationType.AuthToken`, and a
100
+ create tokens survives the revocation of the token that leaked. `createAccessToken` and any other
101
+ minting path (an OAuth consent approval calls `refuseTokenAuth` before it snapshots the subject)
102
+ refuse a request whose `auth.type` is `AuthroizationType.AuthToken` — always, with no option — and a
102
103
  deployment additionally names those routes in the guard's **deny list**, so the refusal is a 401 at
103
104
  the boundary rather than a check every future handler has to remember.
104
105
 
106
+ `revokeAccessToken(protocol)` refuses a token the same way by default. `revokeAccessToken(protocol,
107
+ { allowAccessTokens: true })` admits one — for a deployment's coding-agent surface that lets a person
108
+ clean up their own tokens (Viable's connector binds it on `connect.account.tokens.revoke`, while the
109
+ browser's own `/tokens/:id` route stays session-only and on the deny list). The option widens the
110
+ CREDENTIAL only: the token revoked must still be the caller's own profile in its own organization
111
+ (`makeEntityScope(req).requireEntityKey()`), a foreign or unknown id still answers `AuthForbidden`
112
+ alike, and a token may revoke itself. Revoking never creates a credential, so it cannot make a
113
+ stolen token permanent; at worst it signs the person's other agents out.
114
+
105
115
  Minting only ever narrows: asking for a scope the caller does not hold is **refused**, not silently
106
116
  dropped — a token that quietly grants less than it was asked for fails later, somewhere else, with
107
117
  an error about the wrong thing. Revoke is idempotent (a retry looks like a second revoke), and
@@ -133,7 +143,7 @@ works.
133
143
  ## Tests
134
144
 
135
145
  `bun test ./tests` in the package — what the guard claims, what it resolves (including that a token
136
- can never outrank its profile), the mint/list/revoke rules, `accessTokenIssuerOf(ctx).issueAccessToken` parity with the route, audience admission, and the coguard's four properties.
146
+ can never outrank its profile), the mint/list/revoke rules (the revoke option: own profile and organization only, minting still refused), `accessTokenIssuerOf(ctx).issueAccessToken` parity with the route, audience admission, and the coguard's four properties.
137
147
 
138
148
  ## Depends On
139
149
 
@@ -6,7 +6,7 @@ export * from './issue.js';
6
6
  export * from './list.js';
7
7
  export * from './create.js';
8
8
  export * from './revoke.js';
9
- export type { IssueAccessTokenRequest, IssueAccessTokenSubject } from './types.js';
9
+ export type { IssueAccessTokenRequest, IssueAccessTokenSubject, RevokeAccessTokenOptions } from './types.js';
10
10
  /** @deprecated compat:factory-refactor — use `accessTokenIssuerOf(ctx).issueAccessToken(…)` */
11
11
  export declare const issueAccessToken: (ctx: AuthTokenContext, subject: IssueAccessTokenSubject, payload: IssueAccessTokenRequest) => Promise<IssuedAccessToken>;
12
12
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/handlers/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,sBAAsB,CAAA;AAC7D,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAA;AAEnD,OAAO,KAAK,EAAE,uBAAuB,EAAE,uBAAuB,EAAE,MAAM,YAAY,CAAA;AAElF,cAAc,aAAa,CAAA;AAC3B,cAAc,YAAY,CAAA;AAC1B,cAAc,WAAW,CAAA;AACzB,cAAc,aAAa,CAAA;AAC3B,cAAc,aAAa,CAAA;AAE3B,YAAY,EAAE,uBAAuB,EAAE,uBAAuB,EAAE,MAAM,YAAY,CAAA;AAElF,+FAA+F;AAC/F,eAAO,MAAM,gBAAgB,QACtB,gBAAgB,WAAW,uBAAuB,WAAW,uBAAuB,KACxF,OAAO,CAAC,iBAAiB,CAAsE,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/handlers/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,sBAAsB,CAAA;AAC7D,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAA;AAEnD,OAAO,KAAK,EAAE,uBAAuB,EAAE,uBAAuB,EAAE,MAAM,YAAY,CAAA;AAElF,cAAc,aAAa,CAAA;AAC3B,cAAc,YAAY,CAAA;AAC1B,cAAc,WAAW,CAAA;AACzB,cAAc,aAAa,CAAA;AAC3B,cAAc,aAAa,CAAA;AAE3B,YAAY,EAAE,uBAAuB,EAAE,uBAAuB,EAAE,wBAAwB,EAAE,MAAM,YAAY,CAAA;AAE5G,+FAA+F;AAC/F,eAAO,MAAM,gBAAgB,QACtB,gBAAgB,WAAW,uBAAuB,WAAW,uBAAuB,KACxF,OAAO,CAAC,iBAAiB,CAAsE,CAAA"}
@@ -1,5 +1,15 @@
1
1
  import type { AuthTokenEntrypoints } from '@owlmeans/auth-token';
2
- export declare const revokeAccessToken: (protocol: AuthTokenEntrypoints['revoke']) => import("@owlmeans/server-entrypoint").BoundEntrypointHandler<import("@owlmeans/entrypoint").EntrypointProtocol<{
2
+ import type { RevokeAccessTokenOptions } from './types.js';
3
+ /**
4
+ * Revoke one of the caller's own tokens.
5
+ *
6
+ * By default only a credential a person produced in a browser may revoke. `allowAccessTokens`
7
+ * admits an access token too — a deployment's coding-agent surface that lets the person clean up
8
+ * their own tokens — and changes nothing else: still the caller's OWN profile in their OWN
9
+ * organization, and a foreign token still answers exactly as an unknown id does. Revoking never
10
+ * mints, so it cannot turn a stolen token into a lasting one; minting stays refused regardless.
11
+ */
12
+ export declare const revokeAccessToken: (protocol: AuthTokenEntrypoints['revoke'], opts?: RevokeAccessTokenOptions) => import("@owlmeans/server-entrypoint").BoundEntrypointHandler<import("@owlmeans/entrypoint").EntrypointProtocol<{
3
13
  params: import("@owlmeans/auth-token").AccessTokenParams;
4
14
  }, {
5
15
  id: string;
@@ -1 +1 @@
1
- {"version":3,"file":"revoke.d.ts","sourceRoot":"","sources":["../../src/handlers/revoke.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,sBAAsB,CAAA;AAOhE,eAAO,MAAM,iBAAiB,aAAc,oBAAoB,CAAC,QAAQ,CAAC;;;;GAqBxE,CAAA"}
1
+ {"version":3,"file":"revoke.d.ts","sourceRoot":"","sources":["../../src/handlers/revoke.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,sBAAsB,CAAA;AAKhE,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,YAAY,CAAA;AAG1D;;;;;;;;GAQG;AACH,eAAO,MAAM,iBAAiB,aAClB,oBAAoB,CAAC,QAAQ,CAAC,SAAQ,wBAAwB;;;;GAsBxE,CAAA"}
@@ -3,9 +3,19 @@ import { makeEntityScope } from '@owlmeans/auth-common';
3
3
  import { handlers } from '@owlmeans/server-api';
4
4
  import { refuseTokenAuth } from './refuse.js';
5
5
  import { tokenRecordUtils } from './utils.js';
6
- export const revokeAccessToken = (protocol) => handlers().params(protocol, async (payload, context, req) => {
6
+ /**
7
+ * Revoke one of the caller's own tokens.
8
+ *
9
+ * By default only a credential a person produced in a browser may revoke. `allowAccessTokens`
10
+ * admits an access token too — a deployment's coding-agent surface that lets the person clean up
11
+ * their own tokens — and changes nothing else: still the caller's OWN profile in their OWN
12
+ * organization, and a foreign token still answers exactly as an unknown id does. Revoking never
13
+ * mints, so it cannot turn a stolen token into a lasting one; minting stays refused regardless.
14
+ */
15
+ export const revokeAccessToken = (protocol, opts = {}) => handlers().params(protocol, async (payload, context, req) => {
7
16
  const ctx = context;
8
- refuseTokenAuth(req, 'token-revoke');
17
+ if (opts.allowAccessTokens !== true)
18
+ refuseTokenAuth(req, 'token-revoke');
9
19
  const entityId = makeEntityScope(req).requireEntityKey();
10
20
  const profileId = req.auth?.profileId;
11
21
  if (profileId == null)
@@ -1 +1 @@
1
- {"version":3,"file":"revoke.js","sourceRoot":"","sources":["../../src/handlers/revoke.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAA;AAE9C,OAAO,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAA;AACvD,OAAO,EAAE,QAAQ,EAAE,MAAM,sBAAsB,CAAA;AAE/C,OAAO,EAAE,eAAe,EAAE,MAAM,aAAa,CAAA;AAC7C,OAAO,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAA;AAE7C,MAAM,CAAC,MAAM,iBAAiB,GAAG,CAAC,QAAwC,EAAE,EAAE,CAAC,QAAQ,EAAoB,CAAC,MAAM,CAAC,QAAQ,EAAE,KAAK,EAAE,OAAO,EAAE,OAAO,EAAE,GAAG,EAAE,EAAE;IAC3J,MAAM,GAAG,GAAG,OAA2B,CAAA;IACvC,eAAe,CAAC,GAAG,EAAE,cAAc,CAAC,CAAA;IAEpC,MAAM,QAAQ,GAAG,eAAe,CAAC,GAAG,CAAC,CAAC,gBAAgB,EAAE,CAAA;IACxD,MAAM,SAAS,GAAG,GAAG,CAAC,IAAI,EAAE,SAAS,CAAA;IACrC,IAAI,SAAS,IAAI,IAAI;QAAE,MAAM,IAAI,aAAa,CAAC,SAAS,CAAC,CAAA;IAEzD,MAAM,MAAM,GAAG,MAAM,gBAAgB,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,OAAO,CAAC,EAAE,CAAC,CAAA;IAClE,6FAA6F;IAC7F,8DAA8D;IAC9D,IAAI,MAAM,IAAI,IAAI,IAAI,MAAM,CAAC,QAAQ,KAAK,QAAQ,IAAI,MAAM,CAAC,SAAS,KAAK,SAAS,EAAE,CAAC;QACrF,MAAM,IAAI,aAAa,CAAC,OAAO,CAAC,CAAA;IAClC,CAAC;IAED,yDAAyD;IACzD,IAAI,MAAM,CAAC,SAAS,IAAI,IAAI,EAAE,CAAC;QAC7B,MAAM,gBAAgB,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,GAAG,MAAM,EAAE,SAAS,EAAE,IAAI,IAAI,EAAE,EAAE,SAAS,EAAE,IAAI,IAAI,EAAE,EAAE,CAAC,CAAA;IACtG,CAAC;IAED,OAAO,EAAE,EAAE,EAAE,OAAO,CAAC,EAAE,EAAE,CAAA;AAC3B,CAAC,CAAC,CAAA"}
1
+ {"version":3,"file":"revoke.js","sourceRoot":"","sources":["../../src/handlers/revoke.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAA;AAE9C,OAAO,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAA;AACvD,OAAO,EAAE,QAAQ,EAAE,MAAM,sBAAsB,CAAA;AAE/C,OAAO,EAAE,eAAe,EAAE,MAAM,aAAa,CAAA;AAE7C,OAAO,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAA;AAE7C;;;;;;;;GAQG;AACH,MAAM,CAAC,MAAM,iBAAiB,GAAG,CAC/B,QAAwC,EAAE,IAAI,GAA6B,EAAE,EAC7E,EAAE,CAAC,QAAQ,EAAoB,CAAC,MAAM,CAAC,QAAQ,EAAE,KAAK,EAAE,OAAO,EAAE,OAAO,EAAE,GAAG,EAAE,EAAE;IACjF,MAAM,GAAG,GAAG,OAA2B,CAAA;IACvC,IAAI,IAAI,CAAC,iBAAiB,KAAK,IAAI;QAAE,eAAe,CAAC,GAAG,EAAE,cAAc,CAAC,CAAA;IAEzE,MAAM,QAAQ,GAAG,eAAe,CAAC,GAAG,CAAC,CAAC,gBAAgB,EAAE,CAAA;IACxD,MAAM,SAAS,GAAG,GAAG,CAAC,IAAI,EAAE,SAAS,CAAA;IACrC,IAAI,SAAS,IAAI,IAAI;QAAE,MAAM,IAAI,aAAa,CAAC,SAAS,CAAC,CAAA;IAEzD,MAAM,MAAM,GAAG,MAAM,gBAAgB,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,OAAO,CAAC,EAAE,CAAC,CAAA;IAClE,6FAA6F;IAC7F,8DAA8D;IAC9D,IAAI,MAAM,IAAI,IAAI,IAAI,MAAM,CAAC,QAAQ,KAAK,QAAQ,IAAI,MAAM,CAAC,SAAS,KAAK,SAAS,EAAE,CAAC;QACrF,MAAM,IAAI,aAAa,CAAC,OAAO,CAAC,CAAA;IAClC,CAAC;IAED,yDAAyD;IACzD,IAAI,MAAM,CAAC,SAAS,IAAI,IAAI,EAAE,CAAC;QAC7B,MAAM,gBAAgB,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,GAAG,MAAM,EAAE,SAAS,EAAE,IAAI,IAAI,EAAE,EAAE,SAAS,EAAE,IAAI,IAAI,EAAE,EAAE,CAAC,CAAA;IACtG,CAAC;IAED,OAAO,EAAE,EAAE,EAAE,OAAO,CAAC,EAAE,EAAE,CAAA;AAC3B,CAAC,CAAC,CAAA"}
@@ -26,4 +26,12 @@ export interface AccessTokenIssuer {
26
26
  */
27
27
  issueAccessToken: (subject: IssueAccessTokenSubject, payload: IssueAccessTokenRequest) => Promise<IssuedAccessToken>;
28
28
  }
29
+ /** How a deployment binds `revokeAccessToken`. */
30
+ export interface RevokeAccessTokenOptions {
31
+ /**
32
+ * Let an access token revoke one of its own person's tokens (itself included). Defaults to
33
+ * `false`: only an interactive session revokes. Ownership is checked the same either way.
34
+ */
35
+ allowAccessTokens?: boolean;
36
+ }
29
37
  //# sourceMappingURL=types.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/handlers/types.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAA;AACzC,OAAO,KAAK,EAAE,iBAAiB,EAAE,iBAAiB,EAAE,MAAM,sBAAsB,CAAA;AAEhF,0FAA0F;AAC1F,MAAM,WAAW,uBAAuB;IACtC,QAAQ,EAAE,MAAM,CAAA;IAChB,MAAM,EAAE,MAAM,CAAA;IACd,SAAS,EAAE,MAAM,CAAA;IACjB,IAAI,EAAE,QAAQ,CAAA;IACd,iFAAiF;IACjF,MAAM,EAAE,MAAM,EAAE,CAAA;CACjB;AAED,MAAM,WAAW,uBAAwB,SAAQ,iBAAiB;IAChE;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAA;CACpB;AAED,gGAAgG;AAChG,MAAM,WAAW,iBAAiB;IAChC;;;;;OAKG;IACH,gBAAgB,EAAE,CAAC,OAAO,EAAE,uBAAuB,EAAE,OAAO,EAAE,uBAAuB,KAAK,OAAO,CAAC,iBAAiB,CAAC,CAAA;CACrH"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/handlers/types.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAA;AACzC,OAAO,KAAK,EAAE,iBAAiB,EAAE,iBAAiB,EAAE,MAAM,sBAAsB,CAAA;AAEhF,0FAA0F;AAC1F,MAAM,WAAW,uBAAuB;IACtC,QAAQ,EAAE,MAAM,CAAA;IAChB,MAAM,EAAE,MAAM,CAAA;IACd,SAAS,EAAE,MAAM,CAAA;IACjB,IAAI,EAAE,QAAQ,CAAA;IACd,iFAAiF;IACjF,MAAM,EAAE,MAAM,EAAE,CAAA;CACjB;AAED,MAAM,WAAW,uBAAwB,SAAQ,iBAAiB;IAChE;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAA;CACpB;AAED,gGAAgG;AAChG,MAAM,WAAW,iBAAiB;IAChC;;;;;OAKG;IACH,gBAAgB,EAAE,CAAC,OAAO,EAAE,uBAAuB,EAAE,OAAO,EAAE,uBAAuB,KAAK,OAAO,CAAC,iBAAiB,CAAC,CAAA;CACrH;AAED,kDAAkD;AAClD,MAAM,WAAW,wBAAwB;IACvC;;;OAGG;IACH,iBAAiB,CAAC,EAAE,OAAO,CAAA;CAC5B"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@owlmeans/server-auth-token",
3
- "version": "0.1.18-rc.33",
3
+ "version": "0.1.18-rc.34",
4
4
  "license": "MIT",
5
5
  "description": "The server half of OwlMeans access tokens — the store, the guard that verifies a presented token, the management handlers and the coguard that admits one on an existing route.",
6
6
  "type": "module",
@@ -24,17 +24,17 @@
24
24
  },
25
25
  "dependencies": {
26
26
  "@noble/hashes": "^2.4.0",
27
- "@owlmeans/auth": "^0.1.18-rc.40",
28
- "@owlmeans/auth-common": "^0.1.18-rc.48",
29
- "@owlmeans/auth-token": "^0.1.18-rc.32",
27
+ "@owlmeans/auth": "^0.1.18-rc.41",
28
+ "@owlmeans/auth-common": "^0.1.18-rc.49",
29
+ "@owlmeans/auth-token": "^0.1.18-rc.33",
30
30
  "@owlmeans/context": "^0.1.18-rc.34",
31
31
  "@owlmeans/entrypoint": "^0.1.18-rc.41",
32
- "@owlmeans/log": "^0.1.18-rc.2",
33
- "@owlmeans/mongo-resource": "^0.1.18-rc.43",
32
+ "@owlmeans/log": "^0.1.18-rc.3",
33
+ "@owlmeans/mongo-resource": "^0.1.18-rc.44",
34
34
  "@owlmeans/resource": "^0.1.18-rc.40",
35
- "@owlmeans/server-api": "^0.1.18-rc.52",
36
- "@owlmeans/server-auth-identity": "^0.1.18-rc.48",
37
- "@owlmeans/server-context": "^0.1.18-rc.43",
35
+ "@owlmeans/server-api": "^0.1.18-rc.53",
36
+ "@owlmeans/server-auth-identity": "^0.1.18-rc.49",
37
+ "@owlmeans/server-context": "^0.1.18-rc.44",
38
38
  "@scure/base": "^2.4.0"
39
39
  },
40
40
  "devDependencies": {
@@ -9,7 +9,7 @@ export * from './list.js'
9
9
  export * from './create.js'
10
10
  export * from './revoke.js'
11
11
 
12
- export type { IssueAccessTokenRequest, IssueAccessTokenSubject } from './types.js'
12
+ export type { IssueAccessTokenRequest, IssueAccessTokenSubject, RevokeAccessTokenOptions } from './types.js'
13
13
 
14
14
  /** @deprecated compat:factory-refactor — use `accessTokenIssuerOf(ctx).issueAccessToken(…)` */
15
15
  export const issueAccessToken = async (
@@ -4,11 +4,23 @@ import { makeEntityScope } from '@owlmeans/auth-common'
4
4
  import { handlers } from '@owlmeans/server-api'
5
5
  import type { AuthTokenContext } from '../types.js'
6
6
  import { refuseTokenAuth } from './refuse.js'
7
+ import type { RevokeAccessTokenOptions } from './types.js'
7
8
  import { tokenRecordUtils } from './utils.js'
8
9
 
9
- export const revokeAccessToken = (protocol: AuthTokenEntrypoints['revoke']) => handlers<AuthTokenContext>().params(protocol, async (payload, context, req) => {
10
+ /**
11
+ * Revoke one of the caller's own tokens.
12
+ *
13
+ * By default only a credential a person produced in a browser may revoke. `allowAccessTokens`
14
+ * admits an access token too — a deployment's coding-agent surface that lets the person clean up
15
+ * their own tokens — and changes nothing else: still the caller's OWN profile in their OWN
16
+ * organization, and a foreign token still answers exactly as an unknown id does. Revoking never
17
+ * mints, so it cannot turn a stolen token into a lasting one; minting stays refused regardless.
18
+ */
19
+ export const revokeAccessToken = (
20
+ protocol: AuthTokenEntrypoints['revoke'], opts: RevokeAccessTokenOptions = {},
21
+ ) => handlers<AuthTokenContext>().params(protocol, async (payload, context, req) => {
10
22
  const ctx = context as AuthTokenContext
11
- refuseTokenAuth(req, 'token-revoke')
23
+ if (opts.allowAccessTokens !== true) refuseTokenAuth(req, 'token-revoke')
12
24
 
13
25
  const entityId = makeEntityScope(req).requireEntityKey()
14
26
  const profileId = req.auth?.profileId
@@ -29,3 +29,12 @@ export interface AccessTokenIssuer {
29
29
  */
30
30
  issueAccessToken: (subject: IssueAccessTokenSubject, payload: IssueAccessTokenRequest) => Promise<IssuedAccessToken>
31
31
  }
32
+
33
+ /** How a deployment binds `revokeAccessToken`. */
34
+ export interface RevokeAccessTokenOptions {
35
+ /**
36
+ * Let an access token revoke one of its own person's tokens (itself included). Defaults to
37
+ * `false`: only an interactive session revokes. Ownership is checked the same either way.
38
+ */
39
+ allowAccessTokens?: boolean
40
+ }
@@ -27,6 +27,7 @@ const protocols = makeAuthTokenEntrypoints()
27
27
  const listAccessTokens = bindListAccessTokens(protocols.list)
28
28
  const createAccessToken = bindCreateAccessToken(protocols.create)
29
29
  const revokeAccessToken = bindRevokeAccessToken(protocols.revoke)
30
+ const revokeByToken = bindRevokeAccessToken(protocols.revoke, { allowAccessTokens: true })
30
31
 
31
32
  const session = (patch: Record<string, unknown> = {}): any => ({
32
33
  headers: {}, params: {}, query: {}, body: {},
@@ -184,4 +185,26 @@ describe('@owlmeans/server-auth-token — listing and revoking', () => {
184
185
  { ...session({ type: AuthroizationType.AuthToken }), params: { id: 'one' } }
185
186
  )).rejects.toThrow()
186
187
  })
188
+
189
+ test('allowAccessTokens lets a token revoke its own person\'s token — and nothing more', async () => {
190
+ const context = await makeTestContext()
191
+ await seedProfile(context)
192
+ await seedToken(context, tokenHashHelper.hashAccessToken('tst_one'), { id: 'one' })
193
+ await seedToken(context, tokenHashHelper.hashAccessToken('tst_other'), { id: 'other', profileId: 'someone-else' })
194
+ await seedToken(context, tokenHashHelper.hashAccessToken('tst_elsewhere'), { id: 'elsewhere', entityId: 'entity-2' })
195
+ const byToken = session({ type: AuthroizationType.AuthToken })
196
+
197
+ expect(await invoke(revokeByToken, context, { ...byToken, params: { id: 'one' } })).toEqual({ id: 'one' })
198
+ expect((await context.resource<any>(AUTH_TOKEN_RESOURCE).load('one')).revokedAt).toBeDefined()
199
+
200
+ // Another person's token, another organization's, and an unknown id answer alike — untouched.
201
+ for (const id of ['other', 'elsewhere', 'nonexistent']) {
202
+ await expect(invoke(revokeByToken, context, { ...byToken, params: { id } })).rejects.toThrow()
203
+ }
204
+ expect((await context.resource<any>(AUTH_TOKEN_RESOURCE).load('other')).revokedAt).toBeUndefined()
205
+ expect((await context.resource<any>(AUTH_TOKEN_RESOURCE).load('elsewhere')).revokedAt).toBeUndefined()
206
+
207
+ // The option widens only the credential: minting is refused to a token exactly as before.
208
+ await expect(invoke(createAccessToken, context, { ...byToken, body: { name: 'minted' } })).rejects.toThrow()
209
+ })
187
210
  })