@owlmeans/auth-token 0.1.18-rc.1
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 +15 -0
- package/agent-meta/manifest.json +16 -0
- package/agent-meta/skills/auth-token/SKILL.md +98 -0
- package/build/carrier.d.ts +16 -0
- package/build/carrier.d.ts.map +1 -0
- package/build/carrier.js +31 -0
- package/build/carrier.js.map +1 -0
- package/build/consts.d.ts +52 -0
- package/build/consts.d.ts.map +1 -0
- package/build/consts.js +52 -0
- package/build/consts.js.map +1 -0
- package/build/entrypoints.d.ts +15 -0
- package/build/entrypoints.d.ts.map +1 -0
- package/build/entrypoints.js +30 -0
- package/build/entrypoints.js.map +1 -0
- package/build/format.d.ts +22 -0
- package/build/format.d.ts.map +1 -0
- package/build/format.js +36 -0
- package/build/format.js.map +1 -0
- package/build/index.d.ts +7 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +6 -0
- package/build/index.js.map +1 -0
- package/build/schemas.d.ts +5 -0
- package/build/schemas.d.ts.map +1 -0
- package/build/schemas.js +28 -0
- package/build/schemas.js.map +1 -0
- package/build/types.d.ts +72 -0
- package/build/types.d.ts.map +1 -0
- package/build/types.js +2 -0
- package/build/types.js.map +1 -0
- package/package.json +42 -0
- package/src/carrier.ts +40 -0
- package/src/consts.ts +61 -0
- package/src/entrypoints.ts +41 -0
- package/src/format.ts +42 -0
- package/src/index.ts +6 -0
- package/src/schemas.ts +31 -0
- package/src/types.ts +79 -0
- package/tests/carrier.spec.ts +49 -0
- package/tests/format.spec.ts +45 -0
- package/tests/tsconfig.json +12 -0
- package/tsconfig.json +8 -0
package/README.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<!-- owlmeans:agent-guidance:start -->
|
|
2
|
+
## Agent guidance
|
|
3
|
+
|
|
4
|
+
This package ships embedded agent skills under `agent-meta/`. After installing your
|
|
5
|
+
`@owlmeans/*` packages, run the OwlMeans agent-skills installer to place them into
|
|
6
|
+
your project's skill store (`.agents/skills/`):
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
npx @owlmeans/agent-skills@^0.1.18-rc.13
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
The embedded files are version-matched to this package release. Do not edit them
|
|
13
|
+
directly — they are regenerated on each publish. To contribute guidance edits,
|
|
14
|
+
open a PR against the source monorepo.
|
|
15
|
+
<!-- owlmeans:agent-guidance:end -->
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 2,
|
|
3
|
+
"package": "@owlmeans/auth-token",
|
|
4
|
+
"version": "0.1.18-rc.1",
|
|
5
|
+
"generatedAt": "2026-09-10T18:54:42.500Z",
|
|
6
|
+
"canonicalRepo": "https://github.com/owlmeans/common",
|
|
7
|
+
"entries": [
|
|
8
|
+
{
|
|
9
|
+
"kind": "skill",
|
|
10
|
+
"name": "auth-token",
|
|
11
|
+
"category": "package-specific",
|
|
12
|
+
"file": "skills/auth-token/SKILL.md",
|
|
13
|
+
"canonicalPath": ".agents/skills/auth-token/SKILL.md"
|
|
14
|
+
}
|
|
15
|
+
]
|
|
16
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: auth-token
|
|
3
|
+
description: How to use @owlmeans/auth-token — the contracts behind long-lived access tokens (API keys) — the token format and its deployment prefix, the three management routes, the Authorization parsing that Bearer needs, and the client-side carrier guard a CLI or an MCP server authenticates with. Auto-invoked when importing the token entrypoints, the carrier guard, parseAuthorizationHeader, or an access-token type.
|
|
4
|
+
user-invocable: false
|
|
5
|
+
---
|
|
6
|
+
<!-- AUTO-GENERATED — do not edit. Regenerate via sync-agent-meta. -->
|
|
7
|
+
|
|
8
|
+
# @owlmeans/auth-token
|
|
9
|
+
|
|
10
|
+
**Layer:** Auth shared
|
|
11
|
+
**Install:** `"@owlmeans/auth-token": "^0.1.18-rc.1"` in `dependencies`
|
|
12
|
+
|
|
13
|
+
The contract half of long-lived access tokens: the record shape, the route declarations, the
|
|
14
|
+
format helpers, and one client-side guard that presents a token it was handed. The server half —
|
|
15
|
+
the store, the verifying guard and the handlers — is `@owlmeans/server-auth-token`; the management
|
|
16
|
+
UI is `@owlmeans/web-auth-token`.
|
|
17
|
+
|
|
18
|
+
## Key Exports
|
|
19
|
+
|
|
20
|
+
| Export | Description |
|
|
21
|
+
|--------|-------------|
|
|
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'` |
|
|
24
|
+
| `parseAuthorizationHeader(header)` | `{ scheme, value }` with the scheme lower-cased, or `null` |
|
|
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
|
+
| `CreateAccessTokenSchema` · `AccessTokenParamsSchema` | The ajv body/params filters |
|
|
27
|
+
| `authToken` | `{ base, list, create, revoke }` route aliases |
|
|
28
|
+
| `GUARD_AUTH_TOKEN` · `AUTH_TOKEN_RESOURCE` · `AUTH_TOKEN_COLLECTION` | `'guard:auth-token'`, `'auth-token:token'`, `'access-token'` |
|
|
29
|
+
| `AUTH_TOKEN_DEFAULT_PREFIX` | `'owl_'` — a deployment overrides it |
|
|
30
|
+
| `AUTH_TOKEN_SECRET_BYTES` (24) · `AUTH_TOKEN_DISPLAY_LENGTH` (8) | 192 bits of randomness; 8 characters kept for display |
|
|
31
|
+
| `AUTH_TOKEN_TOUCH_INTERVAL` (5 min) · `AUTH_TOKEN_MAX_TTL` (366 d) · `AUTH_TOKEN_NAME_MAX` (64) | Tuning |
|
|
32
|
+
| `AUTH_TOKEN_SCHEME` · `BEARER_SCHEME` | `'auth-token'`, `'bearer'` — lower-cased, for comparison |
|
|
33
|
+
| `AccessTokenRecord`, `AccessTokenView`, `CreateAccessToken`, `IssuedAccessToken`, `AccessTokenList`, `TokenCarrierOptions`, `AuthTokenEntrypointOptions` | Types |
|
|
34
|
+
|
|
35
|
+
## The prefix is what makes a token CLAIMABLE
|
|
36
|
+
|
|
37
|
+
A token is `<prefix><base58(24 random bytes)>`. The prefix is per deployment (`vib_`, `acme_`), and
|
|
38
|
+
the guard answers `match` only for a value that starts with it — so an access token and an Ed25519
|
|
39
|
+
session bearer arrive under the same `Authorization` header without either guard shadowing the
|
|
40
|
+
other, and two deployments never mistake each other's credentials for their own.
|
|
41
|
+
|
|
42
|
+
The plaintext exists exactly once, in the create response. What is stored is its hash; what a list
|
|
43
|
+
shows forever after is `display` — the prefix plus 8 characters, enough to tell two of your own
|
|
44
|
+
tokens apart and far too little to replay.
|
|
45
|
+
|
|
46
|
+
## Both `AUTH-TOKEN` and `Bearer` are accepted, and that is why this package parses headers itself
|
|
47
|
+
|
|
48
|
+
A third-party client configured with a URL sends `Bearer` whatever the documentation says. But
|
|
49
|
+
`extractAuthToken` (`@owlmeans/auth-common`) compares the prefix against `type.toUpperCase()`, so it
|
|
50
|
+
matches `AUTH-TOKEN` and can **never** match `Bearer`. `parseAuthorizationHeader` is the
|
|
51
|
+
replacement: it lower-cases the scheme so a caller compares once, takes the first of several headers
|
|
52
|
+
a proxy folded together, keeps a value that itself contains spaces, and answers `null` for a scheme
|
|
53
|
+
with no value behind it.
|
|
54
|
+
|
|
55
|
+
## The carrier guard is how a process with no browser authenticates
|
|
56
|
+
|
|
57
|
+
`authMiddleware` asks every guard an entrypoint declares for `authenticated(req)` and stamps the
|
|
58
|
+
first non-null answer onto the header — so registering the carrier under the alias the routes
|
|
59
|
+
already name (`DEFAULT_GUARD`, usually) makes an **unchanged route declaration** work from a CLI, an
|
|
60
|
+
MCP server or a test.
|
|
61
|
+
|
|
62
|
+
```typescript
|
|
63
|
+
context.registerService(makeTokenCarrierGuard(DEFAULT_GUARD, { token, scheme: 'auth-token' }))
|
|
64
|
+
context.registerMiddleware(authMiddleware)
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
There is no session, no refresh and no storage: the token is a long-lived credential the caller was
|
|
68
|
+
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.
|
|
70
|
+
|
|
71
|
+
## The management surface is deliberately not a CRUD
|
|
72
|
+
|
|
73
|
+
There is no update. A token's scopes and lifetime are fixed at issuance, because a token that can be
|
|
74
|
+
widened later is a grant nobody can reason about from the moment it was created.
|
|
75
|
+
|
|
76
|
+
```typescript
|
|
77
|
+
context.registerEntrypoints(makeAuthTokenEntrypoints({ parent: account.base, path: '/tokens' }))
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Mount it under an account section: the ownership gate that already guards a person's own settings
|
|
81
|
+
then guards the credentials that speak for them. A base with a parent inherits its guard and gate; a
|
|
82
|
+
base without one carries `opts.guard`.
|
|
83
|
+
|
|
84
|
+
`CreateAccessToken.expiresIn` is **seconds** (the server clamps it to `AUTH_TOKEN_MAX_TTL`), while a
|
|
85
|
+
UI usually offers days — convert at the form, and omit the field entirely for "never" rather than
|
|
86
|
+
sending a zero.
|
|
87
|
+
|
|
88
|
+
## Depends On
|
|
89
|
+
|
|
90
|
+
- `@owlmeans/auth` — `AuthRole`, the `Authorization` header name
|
|
91
|
+
- `@owlmeans/context`, `@owlmeans/entrypoint`, `@owlmeans/resource`, `@owlmeans/route`
|
|
92
|
+
|
|
93
|
+
## Related
|
|
94
|
+
|
|
95
|
+
- [[server-auth-token]] — the store, the verifying guard, the handlers and the coguard
|
|
96
|
+
- [[web-auth-token]] — the management panel and its hook
|
|
97
|
+
- [[auth-protocol]] — where long-lived tokens sit among the other authentication paths
|
|
98
|
+
- [[auth-common]] — `authMiddleware`, `DEFAULT_GUARD`, `extractAuthToken`
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { GuardService } from '@owlmeans/entrypoint';
|
|
2
|
+
import type { TokenCarrierOptions } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* A client-side guard that carries one access token.
|
|
5
|
+
*
|
|
6
|
+
* `authMiddleware` asks every guard an entrypoint declares for `authenticated(req)` and stamps the
|
|
7
|
+
* first non-null answer onto the `Authorization` header — so registering this under the alias the
|
|
8
|
+
* routes already name (`DEFAULT_GUARD`, usually) makes an unchanged route declaration work for a
|
|
9
|
+
* non-browser client. There is no session, no refresh and no storage: the token is a long-lived
|
|
10
|
+
* credential the caller was handed, and this is the object that presents it.
|
|
11
|
+
*
|
|
12
|
+
* The token may be a value or a thunk, because a CLI reads it from an environment variable that a
|
|
13
|
+
* long-running process should not cache past a reconfiguration.
|
|
14
|
+
*/
|
|
15
|
+
export declare const makeTokenCarrierGuard: (alias: string, opts: TokenCarrierOptions) => GuardService;
|
|
16
|
+
//# sourceMappingURL=carrier.d.ts.map
|
|
@@ -0,0 +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,YAsBhF,CAAA"}
|
package/build/carrier.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { createService } from '@owlmeans/context';
|
|
2
|
+
import { AUTH_TOKEN_SCHEME, BEARER_SCHEME } from './consts.js';
|
|
3
|
+
/**
|
|
4
|
+
* A client-side guard that carries one access token.
|
|
5
|
+
*
|
|
6
|
+
* `authMiddleware` asks every guard an entrypoint declares for `authenticated(req)` and stamps the
|
|
7
|
+
* first non-null answer onto the `Authorization` header — so registering this under the alias the
|
|
8
|
+
* routes already name (`DEFAULT_GUARD`, usually) makes an unchanged route declaration work for a
|
|
9
|
+
* non-browser client. There is no session, no refresh and no storage: the token is a long-lived
|
|
10
|
+
* credential the caller was handed, and this is the object that presents it.
|
|
11
|
+
*
|
|
12
|
+
* The token may be a value or a thunk, because a CLI reads it from an environment variable that a
|
|
13
|
+
* long-running process should not cache past a reconfiguration.
|
|
14
|
+
*/
|
|
15
|
+
export const makeTokenCarrierGuard = (alias, opts) => {
|
|
16
|
+
const scheme = opts.scheme === BEARER_SCHEME ? 'Bearer' : AUTH_TOKEN_SCHEME.toUpperCase();
|
|
17
|
+
const resolve = async () => {
|
|
18
|
+
const token = typeof opts.token === 'function' ? await opts.token() : opts.token;
|
|
19
|
+
return token == null || token === '' ? null : token;
|
|
20
|
+
};
|
|
21
|
+
const service = createService(alias, {
|
|
22
|
+
match: async () => await resolve() != null,
|
|
23
|
+
handle: async () => void 0,
|
|
24
|
+
authenticated: async () => {
|
|
25
|
+
const token = await resolve();
|
|
26
|
+
return token == null ? null : `${scheme} ${token}`;
|
|
27
|
+
},
|
|
28
|
+
}, service => async () => { service.initialized = true; });
|
|
29
|
+
return service;
|
|
30
|
+
};
|
|
31
|
+
//# sourceMappingURL=carrier.js.map
|
|
@@ -0,0 +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;IAEzD,OAAO,OAAO,CAAA;AAChB,CAAC,CAAA"}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/** The guard alias a long-lived access token is verified by. */
|
|
2
|
+
export declare const GUARD_AUTH_TOKEN = "guard:auth-token";
|
|
3
|
+
/** The resource alias holding access-token records. */
|
|
4
|
+
export declare const AUTH_TOKEN_RESOURCE = "auth-token:token";
|
|
5
|
+
/** The collection an access-token resource is backed by, where the backend has collections. */
|
|
6
|
+
export declare const AUTH_TOKEN_COLLECTION = "access-token";
|
|
7
|
+
/**
|
|
8
|
+
* The default prefix every issued token carries.
|
|
9
|
+
*
|
|
10
|
+
* The prefix is what makes a token CLAIMABLE: the guard answers `match` only for a value that
|
|
11
|
+
* starts with it, so an access token and a session bearer can share the `Bearer` scheme without
|
|
12
|
+
* either guard shadowing the other. A deployment overrides it with its own — `vib_`, `acme_` — and
|
|
13
|
+
* two deployments then never mistake each other's credentials for their own.
|
|
14
|
+
*/
|
|
15
|
+
export declare const AUTH_TOKEN_DEFAULT_PREFIX = "owl_";
|
|
16
|
+
/** Random bytes behind one token. 24 bytes ≈ 192 bits, base58-encoded to 33 characters. */
|
|
17
|
+
export declare const AUTH_TOKEN_SECRET_BYTES = 24;
|
|
18
|
+
/**
|
|
19
|
+
* How many characters of a token are kept for display.
|
|
20
|
+
*
|
|
21
|
+
* The plaintext is shown once, at creation, and never again — what a list shows is the prefix plus
|
|
22
|
+
* this many characters, which is enough for a person to tell two of their own tokens apart and far
|
|
23
|
+
* too little to use.
|
|
24
|
+
*/
|
|
25
|
+
export declare const AUTH_TOKEN_DISPLAY_LENGTH = 8;
|
|
26
|
+
/**
|
|
27
|
+
* How often a token's `lastUsedAt` is written.
|
|
28
|
+
*
|
|
29
|
+
* Every request would mean a database write per API call for a field nobody reads in real time.
|
|
30
|
+
* Five minutes answers the only question the field exists for — "is this token still in use?" —
|
|
31
|
+
* at a thousandth of the cost.
|
|
32
|
+
*/
|
|
33
|
+
export declare const AUTH_TOKEN_TOUCH_INTERVAL: number;
|
|
34
|
+
/** The longest lifetime a token may be issued for. */
|
|
35
|
+
export declare const AUTH_TOKEN_MAX_TTL: number;
|
|
36
|
+
export declare const AUTH_TOKEN_NAME_MAX = 64;
|
|
37
|
+
/**
|
|
38
|
+
* The route aliases of the token-management surface.
|
|
39
|
+
*
|
|
40
|
+
* Declared here so a client and a server address the same names, and mounted under whatever parent
|
|
41
|
+
* the application chooses — an account section, a settings section, a dedicated prefix.
|
|
42
|
+
*/
|
|
43
|
+
export declare const authToken: Readonly<{
|
|
44
|
+
base: "auth-token:base";
|
|
45
|
+
list: "auth-token:list";
|
|
46
|
+
create: "auth-token:create";
|
|
47
|
+
revoke: "auth-token:revoke";
|
|
48
|
+
}>;
|
|
49
|
+
/** The `Authorization` schemes a token may arrive under, lower-cased. */
|
|
50
|
+
export declare const AUTH_TOKEN_SCHEME = "auth-token";
|
|
51
|
+
export declare const BEARER_SCHEME = "bearer";
|
|
52
|
+
//# sourceMappingURL=consts.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"consts.d.ts","sourceRoot":"","sources":["../src/consts.ts"],"names":[],"mappings":"AAAA,gEAAgE;AAChE,eAAO,MAAM,gBAAgB,qBAAqB,CAAA;AAElD,uDAAuD;AACvD,eAAO,MAAM,mBAAmB,qBAAqB,CAAA;AAErD,+FAA+F;AAC/F,eAAO,MAAM,qBAAqB,iBAAiB,CAAA;AAEnD;;;;;;;GAOG;AACH,eAAO,MAAM,yBAAyB,SAAS,CAAA;AAE/C,2FAA2F;AAC3F,eAAO,MAAM,uBAAuB,KAAK,CAAA;AAEzC;;;;;;GAMG;AACH,eAAO,MAAM,yBAAyB,IAAI,CAAA;AAE1C;;;;;;GAMG;AACH,eAAO,MAAM,yBAAyB,QAAgB,CAAA;AAEtD,sDAAsD;AACtD,eAAO,MAAM,kBAAkB,QAA4B,CAAA;AAE3D,eAAO,MAAM,mBAAmB,KAAK,CAAA;AAErC;;;;;GAKG;AACH,eAAO,MAAM,SAAS;;;;;EAKpB,CAAA;AAEF,yEAAyE;AACzE,eAAO,MAAM,iBAAiB,eAAe,CAAA;AAC7C,eAAO,MAAM,aAAa,WAAW,CAAA"}
|
package/build/consts.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/** The guard alias a long-lived access token is verified by. */
|
|
2
|
+
export const GUARD_AUTH_TOKEN = 'guard:auth-token';
|
|
3
|
+
/** The resource alias holding access-token records. */
|
|
4
|
+
export const AUTH_TOKEN_RESOURCE = 'auth-token:token';
|
|
5
|
+
/** The collection an access-token resource is backed by, where the backend has collections. */
|
|
6
|
+
export const AUTH_TOKEN_COLLECTION = 'access-token';
|
|
7
|
+
/**
|
|
8
|
+
* The default prefix every issued token carries.
|
|
9
|
+
*
|
|
10
|
+
* The prefix is what makes a token CLAIMABLE: the guard answers `match` only for a value that
|
|
11
|
+
* starts with it, so an access token and a session bearer can share the `Bearer` scheme without
|
|
12
|
+
* either guard shadowing the other. A deployment overrides it with its own — `vib_`, `acme_` — and
|
|
13
|
+
* two deployments then never mistake each other's credentials for their own.
|
|
14
|
+
*/
|
|
15
|
+
export const AUTH_TOKEN_DEFAULT_PREFIX = 'owl_';
|
|
16
|
+
/** Random bytes behind one token. 24 bytes ≈ 192 bits, base58-encoded to 33 characters. */
|
|
17
|
+
export const AUTH_TOKEN_SECRET_BYTES = 24;
|
|
18
|
+
/**
|
|
19
|
+
* How many characters of a token are kept for display.
|
|
20
|
+
*
|
|
21
|
+
* The plaintext is shown once, at creation, and never again — what a list shows is the prefix plus
|
|
22
|
+
* this many characters, which is enough for a person to tell two of their own tokens apart and far
|
|
23
|
+
* too little to use.
|
|
24
|
+
*/
|
|
25
|
+
export const AUTH_TOKEN_DISPLAY_LENGTH = 8;
|
|
26
|
+
/**
|
|
27
|
+
* How often a token's `lastUsedAt` is written.
|
|
28
|
+
*
|
|
29
|
+
* Every request would mean a database write per API call for a field nobody reads in real time.
|
|
30
|
+
* Five minutes answers the only question the field exists for — "is this token still in use?" —
|
|
31
|
+
* at a thousandth of the cost.
|
|
32
|
+
*/
|
|
33
|
+
export const AUTH_TOKEN_TOUCH_INTERVAL = 5 * 60 * 1000;
|
|
34
|
+
/** The longest lifetime a token may be issued for. */
|
|
35
|
+
export const AUTH_TOKEN_MAX_TTL = 366 * 24 * 60 * 60 * 1000;
|
|
36
|
+
export const AUTH_TOKEN_NAME_MAX = 64;
|
|
37
|
+
/**
|
|
38
|
+
* The route aliases of the token-management surface.
|
|
39
|
+
*
|
|
40
|
+
* Declared here so a client and a server address the same names, and mounted under whatever parent
|
|
41
|
+
* the application chooses — an account section, a settings section, a dedicated prefix.
|
|
42
|
+
*/
|
|
43
|
+
export const authToken = Object.freeze({
|
|
44
|
+
base: 'auth-token:base',
|
|
45
|
+
list: 'auth-token:list',
|
|
46
|
+
create: 'auth-token:create',
|
|
47
|
+
revoke: 'auth-token:revoke',
|
|
48
|
+
});
|
|
49
|
+
/** The `Authorization` schemes a token may arrive under, lower-cased. */
|
|
50
|
+
export const AUTH_TOKEN_SCHEME = 'auth-token';
|
|
51
|
+
export const BEARER_SCHEME = 'bearer';
|
|
52
|
+
//# sourceMappingURL=consts.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"consts.js","sourceRoot":"","sources":["../src/consts.ts"],"names":[],"mappings":"AAAA,gEAAgE;AAChE,MAAM,CAAC,MAAM,gBAAgB,GAAG,kBAAkB,CAAA;AAElD,uDAAuD;AACvD,MAAM,CAAC,MAAM,mBAAmB,GAAG,kBAAkB,CAAA;AAErD,+FAA+F;AAC/F,MAAM,CAAC,MAAM,qBAAqB,GAAG,cAAc,CAAA;AAEnD;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,yBAAyB,GAAG,MAAM,CAAA;AAE/C,2FAA2F;AAC3F,MAAM,CAAC,MAAM,uBAAuB,GAAG,EAAE,CAAA;AAEzC;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,yBAAyB,GAAG,CAAC,CAAA;AAE1C;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,yBAAyB,GAAG,CAAC,GAAG,EAAE,GAAG,IAAI,CAAA;AAEtD,sDAAsD;AACtD,MAAM,CAAC,MAAM,kBAAkB,GAAG,GAAG,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,IAAI,CAAA;AAE3D,MAAM,CAAC,MAAM,mBAAmB,GAAG,EAAE,CAAA;AAErC;;;;;GAKG;AACH,MAAM,CAAC,MAAM,SAAS,GAAG,MAAM,CAAC,MAAM,CAAC;IACrC,IAAI,EAAE,iBAAiB;IACvB,IAAI,EAAE,iBAAiB;IACvB,MAAM,EAAE,mBAAmB;IAC3B,MAAM,EAAE,mBAAmB;CAC5B,CAAC,CAAA;AAEF,yEAAyE;AACzE,MAAM,CAAC,MAAM,iBAAiB,GAAG,YAAY,CAAA;AAC7C,MAAM,CAAC,MAAM,aAAa,GAAG,QAAQ,CAAA"}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { CommonEntrypoint } from '@owlmeans/entrypoint';
|
|
2
|
+
import type { AuthTokenEntrypointOptions } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* The three routes a token surface needs, ready to be spread into an application's entrypoints.
|
|
5
|
+
*
|
|
6
|
+
* Mounted under whatever parent the application chooses — an account section usually, so the
|
|
7
|
+
* ownership gate that already protects a person's own settings protects their tokens too. A base
|
|
8
|
+
* with a parent inherits its guard and its gate; a base without one carries `opts.guard`.
|
|
9
|
+
*
|
|
10
|
+
* Deliberately not a resource CRUD: there is no update. A token's scopes and lifetime are fixed at
|
|
11
|
+
* issuance, because a token that can be widened later is a token whose grant nobody can reason
|
|
12
|
+
* about from the moment it was created.
|
|
13
|
+
*/
|
|
14
|
+
export declare const makeAuthTokenEntrypoints: (opts?: AuthTokenEntrypointOptions) => CommonEntrypoint[];
|
|
15
|
+
//# sourceMappingURL=entrypoints.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"entrypoints.d.ts","sourceRoot":"","sources":["../src/entrypoints.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAA;AAI5D,OAAO,KAAK,EAAE,0BAA0B,EAAE,MAAM,YAAY,CAAA;AAE5D;;;;;;;;;;GAUG;AACH,eAAO,MAAM,wBAAwB,UAC7B,0BAA0B,KAC/B,gBAAgB,EAoBlB,CAAA"}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { body, entrypoint, filter, guard, params } from '@owlmeans/entrypoint';
|
|
2
|
+
import { route, RouteMethod } from '@owlmeans/route';
|
|
3
|
+
import { authToken } from './consts.js';
|
|
4
|
+
import { AccessTokenParamsSchema, CreateAccessTokenSchema } from './schemas.js';
|
|
5
|
+
/**
|
|
6
|
+
* The three routes a token surface needs, ready to be spread into an application's entrypoints.
|
|
7
|
+
*
|
|
8
|
+
* Mounted under whatever parent the application chooses — an account section usually, so the
|
|
9
|
+
* ownership gate that already protects a person's own settings protects their tokens too. A base
|
|
10
|
+
* with a parent inherits its guard and its gate; a base without one carries `opts.guard`.
|
|
11
|
+
*
|
|
12
|
+
* Deliberately not a resource CRUD: there is no update. A token's scopes and lifetime are fixed at
|
|
13
|
+
* issuance, because a token that can be widened later is a token whose grant nobody can reason
|
|
14
|
+
* about from the moment it was created.
|
|
15
|
+
*/
|
|
16
|
+
export const makeAuthTokenEntrypoints = (opts = {}) => {
|
|
17
|
+
const path = opts.path ?? '/tokens';
|
|
18
|
+
const base = opts.parent != null
|
|
19
|
+
? entrypoint(route(authToken.base, path, { parent: opts.parent }))
|
|
20
|
+
: entrypoint(route(authToken.base, path), opts.guard != null ? guard(opts.guard) : undefined);
|
|
21
|
+
return [
|
|
22
|
+
base,
|
|
23
|
+
entrypoint(route(authToken.list, '/', {
|
|
24
|
+
parent: authToken.base, method: RouteMethod.GET
|
|
25
|
+
})),
|
|
26
|
+
entrypoint(route(authToken.create, '/', { parent: authToken.base, method: RouteMethod.POST }), filter(body(CreateAccessTokenSchema))),
|
|
27
|
+
entrypoint(route(authToken.revoke, '/:id', { parent: authToken.base, method: RouteMethod.DELETE }), filter(params(AccessTokenParamsSchema))),
|
|
28
|
+
];
|
|
29
|
+
};
|
|
30
|
+
//# sourceMappingURL=entrypoints.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"entrypoints.js","sourceRoot":"","sources":["../src/entrypoints.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,UAAU,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,sBAAsB,CAAA;AAE9E,OAAO,EAAE,KAAK,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAA;AACpD,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA;AACvC,OAAO,EAAE,uBAAuB,EAAE,uBAAuB,EAAE,MAAM,cAAc,CAAA;AAG/E;;;;;;;;;;GAUG;AACH,MAAM,CAAC,MAAM,wBAAwB,GAAG,CACtC,IAAI,GAA+B,EAAE,EACjB,EAAE;IACtB,MAAM,IAAI,GAAG,IAAI,CAAC,IAAI,IAAI,SAAS,CAAA;IACnC,MAAM,IAAI,GAAG,IAAI,CAAC,MAAM,IAAI,IAAI;QAC9B,CAAC,CAAC,UAAU,CAAC,KAAK,CAAC,SAAS,CAAC,IAAI,EAAE,IAAI,EAAE,EAAE,MAAM,EAAE,IAAI,CAAC,MAAM,EAAE,CAAC,CAAC;QAClE,CAAC,CAAC,UAAU,CAAC,KAAK,CAAC,SAAS,CAAC,IAAI,EAAE,IAAI,CAAC,EAAE,IAAI,CAAC,KAAK,IAAI,IAAI,CAAC,CAAC,CAAC,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,CAAA;IAE/F,OAAO;QACL,IAAI;QACJ,UAAU,CAAC,KAAK,CAAC,SAAS,CAAC,IAAI,EAAE,GAAG,EAAE;YACpC,MAAM,EAAE,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,WAAW,CAAC,GAAG;SAChD,CAAC,CAAC;QACH,UAAU,CACR,KAAK,CAAC,SAAS,CAAC,MAAM,EAAE,GAAG,EAAE,EAAE,MAAM,EAAE,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,WAAW,CAAC,IAAI,EAAE,CAAC,EAClF,MAAM,CAAC,IAAI,CAAC,uBAAuB,CAAC,CAAC,CACtC;QACD,UAAU,CACR,KAAK,CAAC,SAAS,CAAC,MAAM,EAAE,MAAM,EAAE,EAAE,MAAM,EAAE,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,WAAW,CAAC,MAAM,EAAE,CAAC,EACvF,MAAM,CAAC,MAAM,CAAC,uBAAuB,CAAC,CAAC,CACxC;KACF,CAAA;AACH,CAAC,CAAA"}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Split an `Authorization` header into its scheme and its value.
|
|
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;
|
|
22
|
+
//# sourceMappingURL=format.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"format.d.ts","sourceRoot":"","sources":["../src/format.ts"],"names":[],"mappings":"AAEA;;;;;;;GAOG;AACH,eAAO,MAAM,wBAAwB,WAC3B,MAAM,GAAG,MAAM,EAAE,GAAG,SAAS,KACpC;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAAG,IAatC,CAAA;AAED,yEAAyE;AACzE,eAAO,MAAM,aAAa,UAAW,MAAM,GAAG,IAAI,GAAG,SAAS,UAAU,MAAM,KAAG,OACN,CAAA;AAE3E;;;;;GAKG;AACH,eAAO,MAAM,SAAS,UAAW,MAAM,UAAU,MAAM,KAAG,MAIzD,CAAA"}
|
package/build/format.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { AUTH_TOKEN_DISPLAY_LENGTH } from './consts.js';
|
|
2
|
+
/**
|
|
3
|
+
* Split an `Authorization` header into its scheme and its value.
|
|
4
|
+
*
|
|
5
|
+
* Deliberately NOT `extractAuthToken` from `@owlmeans/auth-common`: that compares the prefix
|
|
6
|
+
* against `type.toUpperCase()`, so it matches `AUTH-TOKEN` and can never match `Bearer` — the
|
|
7
|
+
* exact spelling every third-party client sends. The scheme comes back lower-cased so a caller
|
|
8
|
+
* compares once.
|
|
9
|
+
*/
|
|
10
|
+
export const parseAuthorizationHeader = (header) => {
|
|
11
|
+
const raw = Array.isArray(header) ? header[0] : header;
|
|
12
|
+
if (raw == null)
|
|
13
|
+
return null;
|
|
14
|
+
const trimmed = raw.trim();
|
|
15
|
+
const space = trimmed.indexOf(' ');
|
|
16
|
+
if (space < 1)
|
|
17
|
+
return null;
|
|
18
|
+
const scheme = trimmed.slice(0, space).toLowerCase();
|
|
19
|
+
const value = trimmed.slice(space + 1).trim();
|
|
20
|
+
if (value.length < 1)
|
|
21
|
+
return null;
|
|
22
|
+
return { scheme, value };
|
|
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)}`;
|
|
35
|
+
};
|
|
36
|
+
//# sourceMappingURL=format.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"format.js","sourceRoot":"","sources":["../src/format.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,yBAAyB,EAAE,MAAM,aAAa,CAAA;AAEvD;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,wBAAwB,GAAG,CACtC,MAAqC,EACK,EAAE;IAC5C,MAAM,GAAG,GAAG,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,MAAM,CAAA;IACtD,IAAI,GAAG,IAAI,IAAI;QAAE,OAAO,IAAI,CAAA;IAE5B,MAAM,OAAO,GAAG,GAAG,CAAC,IAAI,EAAE,CAAA;IAC1B,MAAM,KAAK,GAAG,OAAO,CAAC,OAAO,CAAC,GAAG,CAAC,CAAA;IAClC,IAAI,KAAK,GAAG,CAAC;QAAE,OAAO,IAAI,CAAA;IAE1B,MAAM,MAAM,GAAG,OAAO,CAAC,KAAK,CAAC,CAAC,EAAE,KAAK,CAAC,CAAC,WAAW,EAAE,CAAA;IACpD,MAAM,KAAK,GAAG,OAAO,CAAC,KAAK,CAAC,KAAK,GAAG,CAAC,CAAC,CAAC,IAAI,EAAE,CAAA;IAC7C,IAAI,KAAK,CAAC,MAAM,GAAG,CAAC;QAAE,OAAO,IAAI,CAAA;IAEjC,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,CAAA;AAC1B,CAAC,CAAA;AAED,yEAAyE;AACzE,MAAM,CAAC,MAAM,aAAa,GAAG,CAAC,KAAgC,EAAE,MAAc,EAAW,EAAE,CACzF,KAAK,IAAI,IAAI,IAAI,KAAK,CAAC,MAAM,GAAG,MAAM,CAAC,MAAM,IAAI,KAAK,CAAC,UAAU,CAAC,MAAM,CAAC,CAAA;AAE3E;;;;;GAKG;AACH,MAAM,CAAC,MAAM,SAAS,GAAG,CAAC,KAAa,EAAE,MAAc,EAAU,EAAE;IACjE,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;IAE5E,OAAO,GAAG,MAAM,GAAG,MAAM,CAAC,KAAK,CAAC,CAAC,EAAE,yBAAyB,CAAC,EAAE,CAAA;AACjE,CAAC,CAAA"}
|
package/build/index.d.ts
ADDED
|
@@ -0,0 +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"}
|
package/build/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","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"}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { JSONSchemaType } from 'ajv';
|
|
2
|
+
import type { AccessTokenParams, CreateAccessToken } from './types.js';
|
|
3
|
+
export declare const CreateAccessTokenSchema: JSONSchemaType<CreateAccessToken>;
|
|
4
|
+
export declare const AccessTokenParamsSchema: JSONSchemaType<AccessTokenParams>;
|
|
5
|
+
//# sourceMappingURL=schemas.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"schemas.d.ts","sourceRoot":"","sources":["../src/schemas.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,KAAK,CAAA;AAEzC,OAAO,KAAK,EAAE,iBAAiB,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAA;AAEtE,eAAO,MAAM,uBAAuB,EAAE,cAAc,CAAC,iBAAiB,CAmBrE,CAAA;AAED,eAAO,MAAM,uBAAuB,EAAE,cAAc,CAAC,iBAAiB,CAKrE,CAAA"}
|
package/build/schemas.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { AUTH_TOKEN_MAX_TTL, AUTH_TOKEN_NAME_MAX } from './consts.js';
|
|
2
|
+
export const CreateAccessTokenSchema = {
|
|
3
|
+
type: 'object',
|
|
4
|
+
properties: {
|
|
5
|
+
name: { type: 'string', minLength: 1, maxLength: AUTH_TOKEN_NAME_MAX },
|
|
6
|
+
scopes: {
|
|
7
|
+
type: 'array',
|
|
8
|
+
nullable: true,
|
|
9
|
+
maxItems: 32,
|
|
10
|
+
items: { type: 'string', minLength: 1, maxLength: 128 },
|
|
11
|
+
},
|
|
12
|
+
expiresIn: {
|
|
13
|
+
type: 'integer',
|
|
14
|
+
nullable: true,
|
|
15
|
+
minimum: 60,
|
|
16
|
+
maximum: Math.floor(AUTH_TOKEN_MAX_TTL / 1000),
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
required: ['name'],
|
|
20
|
+
additionalProperties: false,
|
|
21
|
+
};
|
|
22
|
+
export const AccessTokenParamsSchema = {
|
|
23
|
+
type: 'object',
|
|
24
|
+
properties: { id: { type: 'string', minLength: 1, maxLength: 128 } },
|
|
25
|
+
required: ['id'],
|
|
26
|
+
additionalProperties: false,
|
|
27
|
+
};
|
|
28
|
+
//# sourceMappingURL=schemas.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"schemas.js","sourceRoot":"","sources":["../src/schemas.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,kBAAkB,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAA;AAGrE,MAAM,CAAC,MAAM,uBAAuB,GAAsC;IACxE,IAAI,EAAE,QAAQ;IACd,UAAU,EAAE;QACV,IAAI,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE,SAAS,EAAE,CAAC,EAAE,SAAS,EAAE,mBAAmB,EAAE;QACtE,MAAM,EAAE;YACN,IAAI,EAAE,OAAO;YACb,QAAQ,EAAE,IAAI;YACd,QAAQ,EAAE,EAAE;YACZ,KAAK,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE,SAAS,EAAE,CAAC,EAAE,SAAS,EAAE,GAAG,EAAE;SACxD;QACD,SAAS,EAAE;YACT,IAAI,EAAE,SAAS;YACf,QAAQ,EAAE,IAAI;YACd,OAAO,EAAE,EAAE;YACX,OAAO,EAAE,IAAI,CAAC,KAAK,CAAC,kBAAkB,GAAG,IAAI,CAAC;SAC/C;KACF;IACD,QAAQ,EAAE,CAAC,MAAM,CAAC;IAClB,oBAAoB,EAAE,KAAK;CAC5B,CAAA;AAED,MAAM,CAAC,MAAM,uBAAuB,GAAsC;IACxE,IAAI,EAAE,QAAQ;IACd,UAAU,EAAE,EAAE,EAAE,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE,SAAS,EAAE,CAAC,EAAE,SAAS,EAAE,GAAG,EAAE,EAAE;IACpE,QAAQ,EAAE,CAAC,IAAI,CAAC;IAChB,oBAAoB,EAAE,KAAK;CAC5B,CAAA"}
|
package/build/types.d.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { AuthRole } from '@owlmeans/auth';
|
|
2
|
+
import type { ResourceRecord } from '@owlmeans/resource';
|
|
3
|
+
/**
|
|
4
|
+
* One long-lived access token, as stored.
|
|
5
|
+
*
|
|
6
|
+
* The plaintext is never here. What is stored is its hash — a stolen database yields no usable
|
|
7
|
+
* credential — plus `display`, the prefix and a few characters, which is what a person sees in a
|
|
8
|
+
* list. A token is bound to the profile that minted it and can never outrank it: the guard
|
|
9
|
+
* intersects its scopes with the profile's on every request, so revoking a profile's access
|
|
10
|
+
* revokes every token it ever issued without touching a single token record.
|
|
11
|
+
*/
|
|
12
|
+
export interface AccessTokenRecord extends ResourceRecord {
|
|
13
|
+
id?: string;
|
|
14
|
+
/** SHA-256 of the plaintext, hex. The only copy of the secret that exists after issuance. */
|
|
15
|
+
hash: string;
|
|
16
|
+
/** Prefix + the first characters of the secret. Shown in lists; useless as a credential. */
|
|
17
|
+
display: string;
|
|
18
|
+
/** What the owner called it. */
|
|
19
|
+
name: string;
|
|
20
|
+
userId: string;
|
|
21
|
+
profileId: string;
|
|
22
|
+
entityId: string;
|
|
23
|
+
scopes: string[];
|
|
24
|
+
role: AuthRole;
|
|
25
|
+
createdAt: Date;
|
|
26
|
+
updatedAt?: Date;
|
|
27
|
+
/** Written at most once per touch interval — a usage signal, not an access log. */
|
|
28
|
+
lastUsedAt?: Date;
|
|
29
|
+
expiresAt?: Date;
|
|
30
|
+
/** Set once and never unset. A revoked token is kept so its display name still resolves. */
|
|
31
|
+
revokedAt?: Date;
|
|
32
|
+
}
|
|
33
|
+
/** What a caller may see. The hash never leaves the server. */
|
|
34
|
+
export type AccessTokenView = Omit<AccessTokenRecord, 'hash'>;
|
|
35
|
+
export interface CreateAccessToken {
|
|
36
|
+
name: string;
|
|
37
|
+
/** A subset of the caller's own scopes. Defaults to all of them. */
|
|
38
|
+
scopes?: string[];
|
|
39
|
+
/** Lifetime in seconds. Absent means no expiry; clamped to the maximum TTL. */
|
|
40
|
+
expiresIn?: number;
|
|
41
|
+
}
|
|
42
|
+
/** The one moment the plaintext exists outside the caller's own machine. */
|
|
43
|
+
export interface IssuedAccessToken {
|
|
44
|
+
token: string;
|
|
45
|
+
record: AccessTokenView;
|
|
46
|
+
}
|
|
47
|
+
export interface AccessTokenList {
|
|
48
|
+
items: AccessTokenView[];
|
|
49
|
+
}
|
|
50
|
+
export interface AccessTokenParams {
|
|
51
|
+
id: string;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* How a client presents a token it already holds.
|
|
55
|
+
*
|
|
56
|
+
* `auth-token` is the OwlMeans scheme; `bearer` is what a third-party client — an MCP host reading
|
|
57
|
+
* a URL configuration, a curl script — will send whatever the documentation says. Both are
|
|
58
|
+
* accepted on the way in; a client chooses the one its transport is comfortable with.
|
|
59
|
+
*/
|
|
60
|
+
export interface TokenCarrierOptions {
|
|
61
|
+
token: string | (() => string | Promise<string>);
|
|
62
|
+
scheme?: 'auth-token' | 'bearer';
|
|
63
|
+
}
|
|
64
|
+
export interface AuthTokenEntrypointOptions {
|
|
65
|
+
/** The entrypoint the token routes hang under. */
|
|
66
|
+
parent?: string;
|
|
67
|
+
/** Path of the token base, relative to the parent. Defaults to `/tokens`. */
|
|
68
|
+
path?: string;
|
|
69
|
+
/** The guard the base carries when it has no parent to inherit one from. */
|
|
70
|
+
guard?: string;
|
|
71
|
+
}
|
|
72
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +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;AAExD;;;;;;;;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;CACjB;AAED,+DAA+D;AAC/D,MAAM,MAAM,eAAe,GAAG,IAAI,CAAC,iBAAiB,EAAE,MAAM,CAAC,CAAA;AAE7D,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;CACjC;AAED,MAAM,WAAW,0BAA0B;IACzC,kDAAkD;IAClD,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,6EAA6E;IAC7E,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,4EAA4E;IAC5E,KAAK,CAAC,EAAE,MAAM,CAAA;CACf"}
|
package/build/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":""}
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@owlmeans/auth-token",
|
|
3
|
+
"version": "0.1.18-rc.1",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"description": "Long-lived access tokens (API keys) on the OwlMeans auth rails — the record shape, the token format, the management entrypoints and the client-side carrier guard.",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "tsc -b",
|
|
9
|
+
"dev": "sleep 2 && nodemon -e ts,tsx,json --watch src --exec \"tsc -p ./tsconfig.json\"",
|
|
10
|
+
"watch": "tsc -b -w --preserveWatchOutput --pretty",
|
|
11
|
+
"test": "bun test ./tests"
|
|
12
|
+
},
|
|
13
|
+
"main": "build/index.js",
|
|
14
|
+
"module": "build/index.js",
|
|
15
|
+
"types": "build/index.d.ts",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"import": "./build/index.js",
|
|
19
|
+
"require": "./build/index.js",
|
|
20
|
+
"default": "./build/index.js",
|
|
21
|
+
"module": "./build/index.js",
|
|
22
|
+
"types": "./build/index.d.ts"
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@owlmeans/auth": "^0.1.18-rc.10",
|
|
27
|
+
"@owlmeans/context": "^0.1.18-rc.9",
|
|
28
|
+
"@owlmeans/entrypoint": "^0.1.18-rc.12",
|
|
29
|
+
"@owlmeans/resource": "^0.1.18-rc.10",
|
|
30
|
+
"@owlmeans/route": "^0.1.18-rc.10"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@owlmeans/dep-config": "workspace:*",
|
|
34
|
+
"@types/bun": "^1.4.0",
|
|
35
|
+
"ajv": "^8.17.1",
|
|
36
|
+
"nodemon": "^3.1.14",
|
|
37
|
+
"typescript": "^7.0.2"
|
|
38
|
+
},
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"access": "public"
|
|
41
|
+
}
|
|
42
|
+
}
|
package/src/carrier.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { createService } from '@owlmeans/context'
|
|
2
|
+
import type { GuardService } from '@owlmeans/entrypoint'
|
|
3
|
+
import { AUTH_TOKEN_SCHEME, BEARER_SCHEME } from './consts.js'
|
|
4
|
+
import type { TokenCarrierOptions } from './types.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A client-side guard that carries one access token.
|
|
8
|
+
*
|
|
9
|
+
* `authMiddleware` asks every guard an entrypoint declares for `authenticated(req)` and stamps the
|
|
10
|
+
* first non-null answer onto the `Authorization` header — so registering this under the alias the
|
|
11
|
+
* routes already name (`DEFAULT_GUARD`, usually) makes an unchanged route declaration work for a
|
|
12
|
+
* non-browser client. There is no session, no refresh and no storage: the token is a long-lived
|
|
13
|
+
* credential the caller was handed, and this is the object that presents it.
|
|
14
|
+
*
|
|
15
|
+
* The token may be a value or a thunk, because a CLI reads it from an environment variable that a
|
|
16
|
+
* long-running process should not cache past a reconfiguration.
|
|
17
|
+
*/
|
|
18
|
+
export const makeTokenCarrierGuard = (alias: string, opts: TokenCarrierOptions): GuardService => {
|
|
19
|
+
const scheme = opts.scheme === BEARER_SCHEME ? 'Bearer' : AUTH_TOKEN_SCHEME.toUpperCase()
|
|
20
|
+
|
|
21
|
+
const resolve = async (): Promise<string | null> => {
|
|
22
|
+
const token = typeof opts.token === 'function' ? await opts.token() : opts.token
|
|
23
|
+
|
|
24
|
+
return token == null || token === '' ? null : token
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const service: GuardService = createService<GuardService>(alias, {
|
|
28
|
+
match: async () => await resolve() != null,
|
|
29
|
+
|
|
30
|
+
handle: async <T>() => void 0 as T,
|
|
31
|
+
|
|
32
|
+
authenticated: async () => {
|
|
33
|
+
const token = await resolve()
|
|
34
|
+
|
|
35
|
+
return token == null ? null : `${scheme} ${token}`
|
|
36
|
+
},
|
|
37
|
+
}, service => async () => { service.initialized = true })
|
|
38
|
+
|
|
39
|
+
return service
|
|
40
|
+
}
|
package/src/consts.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/** The guard alias a long-lived access token is verified by. */
|
|
2
|
+
export const GUARD_AUTH_TOKEN = 'guard:auth-token'
|
|
3
|
+
|
|
4
|
+
/** The resource alias holding access-token records. */
|
|
5
|
+
export const AUTH_TOKEN_RESOURCE = 'auth-token:token'
|
|
6
|
+
|
|
7
|
+
/** The collection an access-token resource is backed by, where the backend has collections. */
|
|
8
|
+
export const AUTH_TOKEN_COLLECTION = 'access-token'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The default prefix every issued token carries.
|
|
12
|
+
*
|
|
13
|
+
* The prefix is what makes a token CLAIMABLE: the guard answers `match` only for a value that
|
|
14
|
+
* starts with it, so an access token and a session bearer can share the `Bearer` scheme without
|
|
15
|
+
* either guard shadowing the other. A deployment overrides it with its own — `vib_`, `acme_` — and
|
|
16
|
+
* two deployments then never mistake each other's credentials for their own.
|
|
17
|
+
*/
|
|
18
|
+
export const AUTH_TOKEN_DEFAULT_PREFIX = 'owl_'
|
|
19
|
+
|
|
20
|
+
/** Random bytes behind one token. 24 bytes ≈ 192 bits, base58-encoded to 33 characters. */
|
|
21
|
+
export const AUTH_TOKEN_SECRET_BYTES = 24
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* How many characters of a token are kept for display.
|
|
25
|
+
*
|
|
26
|
+
* The plaintext is shown once, at creation, and never again — what a list shows is the prefix plus
|
|
27
|
+
* this many characters, which is enough for a person to tell two of their own tokens apart and far
|
|
28
|
+
* too little to use.
|
|
29
|
+
*/
|
|
30
|
+
export const AUTH_TOKEN_DISPLAY_LENGTH = 8
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* How often a token's `lastUsedAt` is written.
|
|
34
|
+
*
|
|
35
|
+
* Every request would mean a database write per API call for a field nobody reads in real time.
|
|
36
|
+
* Five minutes answers the only question the field exists for — "is this token still in use?" —
|
|
37
|
+
* at a thousandth of the cost.
|
|
38
|
+
*/
|
|
39
|
+
export const AUTH_TOKEN_TOUCH_INTERVAL = 5 * 60 * 1000
|
|
40
|
+
|
|
41
|
+
/** The longest lifetime a token may be issued for. */
|
|
42
|
+
export const AUTH_TOKEN_MAX_TTL = 366 * 24 * 60 * 60 * 1000
|
|
43
|
+
|
|
44
|
+
export const AUTH_TOKEN_NAME_MAX = 64
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The route aliases of the token-management surface.
|
|
48
|
+
*
|
|
49
|
+
* Declared here so a client and a server address the same names, and mounted under whatever parent
|
|
50
|
+
* the application chooses — an account section, a settings section, a dedicated prefix.
|
|
51
|
+
*/
|
|
52
|
+
export const authToken = Object.freeze({
|
|
53
|
+
base: 'auth-token:base',
|
|
54
|
+
list: 'auth-token:list',
|
|
55
|
+
create: 'auth-token:create',
|
|
56
|
+
revoke: 'auth-token:revoke',
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
/** The `Authorization` schemes a token may arrive under, lower-cased. */
|
|
60
|
+
export const AUTH_TOKEN_SCHEME = 'auth-token'
|
|
61
|
+
export const BEARER_SCHEME = 'bearer'
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { body, entrypoint, filter, guard, params } from '@owlmeans/entrypoint'
|
|
2
|
+
import type { CommonEntrypoint } from '@owlmeans/entrypoint'
|
|
3
|
+
import { route, RouteMethod } from '@owlmeans/route'
|
|
4
|
+
import { authToken } from './consts.js'
|
|
5
|
+
import { AccessTokenParamsSchema, CreateAccessTokenSchema } from './schemas.js'
|
|
6
|
+
import type { AuthTokenEntrypointOptions } from './types.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The three routes a token surface needs, ready to be spread into an application's entrypoints.
|
|
10
|
+
*
|
|
11
|
+
* Mounted under whatever parent the application chooses — an account section usually, so the
|
|
12
|
+
* ownership gate that already protects a person's own settings protects their tokens too. A base
|
|
13
|
+
* with a parent inherits its guard and its gate; a base without one carries `opts.guard`.
|
|
14
|
+
*
|
|
15
|
+
* Deliberately not a resource CRUD: there is no update. A token's scopes and lifetime are fixed at
|
|
16
|
+
* issuance, because a token that can be widened later is a token whose grant nobody can reason
|
|
17
|
+
* about from the moment it was created.
|
|
18
|
+
*/
|
|
19
|
+
export const makeAuthTokenEntrypoints = (
|
|
20
|
+
opts: AuthTokenEntrypointOptions = {}
|
|
21
|
+
): CommonEntrypoint[] => {
|
|
22
|
+
const path = opts.path ?? '/tokens'
|
|
23
|
+
const base = opts.parent != null
|
|
24
|
+
? entrypoint(route(authToken.base, path, { parent: opts.parent }))
|
|
25
|
+
: entrypoint(route(authToken.base, path), opts.guard != null ? guard(opts.guard) : undefined)
|
|
26
|
+
|
|
27
|
+
return [
|
|
28
|
+
base,
|
|
29
|
+
entrypoint(route(authToken.list, '/', {
|
|
30
|
+
parent: authToken.base, method: RouteMethod.GET
|
|
31
|
+
})),
|
|
32
|
+
entrypoint(
|
|
33
|
+
route(authToken.create, '/', { parent: authToken.base, method: RouteMethod.POST }),
|
|
34
|
+
filter(body(CreateAccessTokenSchema))
|
|
35
|
+
),
|
|
36
|
+
entrypoint(
|
|
37
|
+
route(authToken.revoke, '/:id', { parent: authToken.base, method: RouteMethod.DELETE }),
|
|
38
|
+
filter(params(AccessTokenParamsSchema))
|
|
39
|
+
),
|
|
40
|
+
]
|
|
41
|
+
}
|
package/src/format.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { AUTH_TOKEN_DISPLAY_LENGTH } from './consts.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Split an `Authorization` header into its scheme and its value.
|
|
5
|
+
*
|
|
6
|
+
* Deliberately NOT `extractAuthToken` from `@owlmeans/auth-common`: that compares the prefix
|
|
7
|
+
* against `type.toUpperCase()`, so it matches `AUTH-TOKEN` and can never match `Bearer` — the
|
|
8
|
+
* exact spelling every third-party client sends. The scheme comes back lower-cased so a caller
|
|
9
|
+
* compares once.
|
|
10
|
+
*/
|
|
11
|
+
export const parseAuthorizationHeader = (
|
|
12
|
+
header: string | string[] | undefined
|
|
13
|
+
): { scheme: string, value: string } | null => {
|
|
14
|
+
const raw = Array.isArray(header) ? header[0] : header
|
|
15
|
+
if (raw == null) return null
|
|
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
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Whether a value looks like an access token this deployment issued. */
|
|
29
|
+
export const isAccessToken = (value: string | null | undefined, prefix: string): boolean =>
|
|
30
|
+
value != null && value.length > prefix.length && value.startsWith(prefix)
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The half of a token that may be shown again.
|
|
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
|
|
40
|
+
|
|
41
|
+
return `${prefix}${secret.slice(0, AUTH_TOKEN_DISPLAY_LENGTH)}`
|
|
42
|
+
}
|
package/src/index.ts
ADDED
package/src/schemas.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { JSONSchemaType } from 'ajv'
|
|
2
|
+
import { AUTH_TOKEN_MAX_TTL, AUTH_TOKEN_NAME_MAX } from './consts.js'
|
|
3
|
+
import type { AccessTokenParams, CreateAccessToken } from './types.js'
|
|
4
|
+
|
|
5
|
+
export const CreateAccessTokenSchema: JSONSchemaType<CreateAccessToken> = {
|
|
6
|
+
type: 'object',
|
|
7
|
+
properties: {
|
|
8
|
+
name: { type: 'string', minLength: 1, maxLength: AUTH_TOKEN_NAME_MAX },
|
|
9
|
+
scopes: {
|
|
10
|
+
type: 'array',
|
|
11
|
+
nullable: true,
|
|
12
|
+
maxItems: 32,
|
|
13
|
+
items: { type: 'string', minLength: 1, maxLength: 128 },
|
|
14
|
+
},
|
|
15
|
+
expiresIn: {
|
|
16
|
+
type: 'integer',
|
|
17
|
+
nullable: true,
|
|
18
|
+
minimum: 60,
|
|
19
|
+
maximum: Math.floor(AUTH_TOKEN_MAX_TTL / 1000),
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
required: ['name'],
|
|
23
|
+
additionalProperties: false,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const AccessTokenParamsSchema: JSONSchemaType<AccessTokenParams> = {
|
|
27
|
+
type: 'object',
|
|
28
|
+
properties: { id: { type: 'string', minLength: 1, maxLength: 128 } },
|
|
29
|
+
required: ['id'],
|
|
30
|
+
additionalProperties: false,
|
|
31
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { AuthRole } from '@owlmeans/auth'
|
|
2
|
+
import type { ResourceRecord } from '@owlmeans/resource'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* One long-lived access token, as stored.
|
|
6
|
+
*
|
|
7
|
+
* The plaintext is never here. What is stored is its hash — a stolen database yields no usable
|
|
8
|
+
* credential — plus `display`, the prefix and a few characters, which is what a person sees in a
|
|
9
|
+
* list. A token is bound to the profile that minted it and can never outrank it: the guard
|
|
10
|
+
* intersects its scopes with the profile's on every request, so revoking a profile's access
|
|
11
|
+
* revokes every token it ever issued without touching a single token record.
|
|
12
|
+
*/
|
|
13
|
+
export interface AccessTokenRecord extends ResourceRecord {
|
|
14
|
+
id?: string
|
|
15
|
+
/** SHA-256 of the plaintext, hex. The only copy of the secret that exists after issuance. */
|
|
16
|
+
hash: string
|
|
17
|
+
/** Prefix + the first characters of the secret. Shown in lists; useless as a credential. */
|
|
18
|
+
display: string
|
|
19
|
+
/** What the owner called it. */
|
|
20
|
+
name: string
|
|
21
|
+
userId: string
|
|
22
|
+
profileId: string
|
|
23
|
+
entityId: string
|
|
24
|
+
scopes: string[]
|
|
25
|
+
role: AuthRole
|
|
26
|
+
createdAt: Date
|
|
27
|
+
updatedAt?: Date
|
|
28
|
+
/** Written at most once per touch interval — a usage signal, not an access log. */
|
|
29
|
+
lastUsedAt?: Date
|
|
30
|
+
expiresAt?: Date
|
|
31
|
+
/** Set once and never unset. A revoked token is kept so its display name still resolves. */
|
|
32
|
+
revokedAt?: Date
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** What a caller may see. The hash never leaves the server. */
|
|
36
|
+
export type AccessTokenView = Omit<AccessTokenRecord, 'hash'>
|
|
37
|
+
|
|
38
|
+
export interface CreateAccessToken {
|
|
39
|
+
name: string
|
|
40
|
+
/** A subset of the caller's own scopes. Defaults to all of them. */
|
|
41
|
+
scopes?: string[]
|
|
42
|
+
/** Lifetime in seconds. Absent means no expiry; clamped to the maximum TTL. */
|
|
43
|
+
expiresIn?: number
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The one moment the plaintext exists outside the caller's own machine. */
|
|
47
|
+
export interface IssuedAccessToken {
|
|
48
|
+
token: string
|
|
49
|
+
record: AccessTokenView
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface AccessTokenList {
|
|
53
|
+
items: AccessTokenView[]
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface AccessTokenParams {
|
|
57
|
+
id: string
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* How a client presents a token it already holds.
|
|
62
|
+
*
|
|
63
|
+
* `auth-token` is the OwlMeans scheme; `bearer` is what a third-party client — an MCP host reading
|
|
64
|
+
* a URL configuration, a curl script — will send whatever the documentation says. Both are
|
|
65
|
+
* accepted on the way in; a client chooses the one its transport is comfortable with.
|
|
66
|
+
*/
|
|
67
|
+
export interface TokenCarrierOptions {
|
|
68
|
+
token: string | (() => string | Promise<string>)
|
|
69
|
+
scheme?: 'auth-token' | 'bearer'
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface AuthTokenEntrypointOptions {
|
|
73
|
+
/** The entrypoint the token routes hang under. */
|
|
74
|
+
parent?: string
|
|
75
|
+
/** Path of the token base, relative to the parent. Defaults to `/tokens`. */
|
|
76
|
+
path?: string
|
|
77
|
+
/** The guard the base carries when it has no parent to inherit one from. */
|
|
78
|
+
guard?: string
|
|
79
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { AppType, makeBasicContext } from '@owlmeans/context'
|
|
3
|
+
import type { BasicConfig, BasicContext } from '@owlmeans/context'
|
|
4
|
+
import type { GuardService } from '@owlmeans/entrypoint'
|
|
5
|
+
import { makeTokenCarrierGuard } from '../src/carrier.js'
|
|
6
|
+
|
|
7
|
+
const contextWith = async (guard: GuardService): Promise<BasicContext<BasicConfig>> => {
|
|
8
|
+
const cfg: BasicConfig = { ready: false, service: 'carrier-tests', type: AppType.Backend, services: {} }
|
|
9
|
+
const context = makeBasicContext(cfg) as BasicContext<BasicConfig>
|
|
10
|
+
context.registerService(guard)
|
|
11
|
+
context.configure()
|
|
12
|
+
await context.init()
|
|
13
|
+
|
|
14
|
+
return context
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('@owlmeans/auth-token — the client carrier guard', () => {
|
|
18
|
+
test('presents the OwlMeans scheme by default', async () => {
|
|
19
|
+
const guard = makeTokenCarrierGuard('carrier', { token: 'owl_secret' })
|
|
20
|
+
await contextWith(guard)
|
|
21
|
+
|
|
22
|
+
expect(await guard.authenticated()).toBe('AUTH-TOKEN owl_secret')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('presents Bearer when asked — what a URL-configured client sends', async () => {
|
|
26
|
+
const guard = makeTokenCarrierGuard('carrier', { token: 'owl_secret', scheme: 'bearer' })
|
|
27
|
+
await contextWith(guard)
|
|
28
|
+
|
|
29
|
+
expect(await guard.authenticated()).toBe('Bearer owl_secret')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test('resolves a thunk on every call, so a reconfigured token is picked up', async () => {
|
|
33
|
+
let current = 'owl_first'
|
|
34
|
+
const guard = makeTokenCarrierGuard('carrier', { token: () => current })
|
|
35
|
+
await contextWith(guard)
|
|
36
|
+
|
|
37
|
+
expect(await guard.authenticated()).toBe('AUTH-TOKEN owl_first')
|
|
38
|
+
current = 'owl_second'
|
|
39
|
+
expect(await guard.authenticated()).toBe('AUTH-TOKEN owl_second')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test('holds no credential when the token is empty, and matches nothing', async () => {
|
|
43
|
+
const guard = makeTokenCarrierGuard('carrier', { token: '' })
|
|
44
|
+
await contextWith(guard)
|
|
45
|
+
|
|
46
|
+
expect(await guard.authenticated()).toBeNull()
|
|
47
|
+
expect(await guard.match({} as any, {} as any)).toBe(false)
|
|
48
|
+
})
|
|
49
|
+
})
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { displayOf, isAccessToken, parseAuthorizationHeader } from '../src/format.js'
|
|
3
|
+
import { AUTH_TOKEN_DEFAULT_PREFIX } from '../src/consts.js'
|
|
4
|
+
|
|
5
|
+
describe('@owlmeans/auth-token — Authorization parsing', () => {
|
|
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')
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
test('takes the first header when a proxy folded several', () => {
|
|
13
|
+
expect(parseAuthorizationHeader(['Bearer one', 'Bearer two'])?.value).toBe('one')
|
|
14
|
+
})
|
|
15
|
+
|
|
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()
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test('keeps a value that itself contains spaces', () => {
|
|
25
|
+
expect(parseAuthorizationHeader('Bearer a b c')?.value).toBe('a b c')
|
|
26
|
+
})
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
describe('@owlmeans/auth-token — token shape', () => {
|
|
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)
|
|
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)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test('the display form keeps the prefix and eight characters of the secret', () => {
|
|
40
|
+
const display = displayOf('owl_ABCDEFGHIJKLMNOP', 'owl_')
|
|
41
|
+
expect(display).toBe('owl_ABCDEFGH')
|
|
42
|
+
// Short enough to be useless, long enough to tell two of a person's tokens apart.
|
|
43
|
+
expect(display.length).toBeLessThan('owl_ABCDEFGHIJKLMNOP'.length)
|
|
44
|
+
})
|
|
45
|
+
})
|