@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 +177 -1
- package/agent-meta/manifest.json +2 -2
- package/agent-meta/skills/server-auth-token/SKILL.md +17 -7
- package/build/handlers/index.d.ts +1 -1
- package/build/handlers/index.d.ts.map +1 -1
- package/build/handlers/revoke.d.ts +11 -1
- package/build/handlers/revoke.d.ts.map +1 -1
- package/build/handlers/revoke.js +12 -2
- package/build/handlers/revoke.js.map +1 -1
- package/build/handlers/types.d.ts +8 -0
- package/build/handlers/types.d.ts.map +1 -1
- package/package.json +9 -9
- package/src/handlers/index.ts +1 -1
- package/src/handlers/revoke.ts +14 -2
- package/src/handlers/types.ts +9 -0
- package/tests/handlers.spec.ts +23 -0
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.
|
|
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
|
package/agent-meta/manifest.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 2,
|
|
3
3
|
"package": "@owlmeans/server-auth-token",
|
|
4
|
-
"version": "0.1.18-rc.
|
|
5
|
-
"generatedAt": "2026-10-
|
|
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.
|
|
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
|
|
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
|
|
101
|
-
`refuseTokenAuth` before it snapshots the subject)
|
|
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;
|
|
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
|
-
|
|
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;
|
|
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"}
|
package/build/handlers/revoke.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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;
|
|
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.
|
|
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.
|
|
28
|
-
"@owlmeans/auth-common": "^0.1.18-rc.
|
|
29
|
-
"@owlmeans/auth-token": "^0.1.18-rc.
|
|
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.
|
|
33
|
-
"@owlmeans/mongo-resource": "^0.1.18-rc.
|
|
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.
|
|
36
|
-
"@owlmeans/server-auth-identity": "^0.1.18-rc.
|
|
37
|
-
"@owlmeans/server-context": "^0.1.18-rc.
|
|
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": {
|
package/src/handlers/index.ts
CHANGED
|
@@ -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 (
|
package/src/handlers/revoke.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
package/src/handlers/types.ts
CHANGED
|
@@ -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
|
+
}
|
package/tests/handlers.spec.ts
CHANGED
|
@@ -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
|
})
|