@owlmeans/server-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/server-auth-token/SKILL.md +136 -0
- package/build/append.d.ts +20 -0
- package/build/append.d.ts.map +1 -0
- package/build/append.js +29 -0
- package/build/append.js.map +1 -0
- package/build/coguard.d.ts +16 -0
- package/build/coguard.d.ts.map +1 -0
- package/build/coguard.js +23 -0
- package/build/coguard.js.map +1 -0
- package/build/consts.d.ts +3 -0
- package/build/consts.d.ts.map +1 -0
- package/build/consts.js +3 -0
- package/build/consts.js.map +1 -0
- package/build/guard.d.ts +24 -0
- package/build/guard.d.ts.map +1 -0
- package/build/guard.js +126 -0
- package/build/guard.js.map +1 -0
- package/build/handlers/index.d.ts +4 -0
- package/build/handlers/index.d.ts.map +1 -0
- package/build/handlers/index.js +94 -0
- package/build/handlers/index.js.map +1 -0
- package/build/hash.d.ts +17 -0
- package/build/hash.d.ts.map +1 -0
- package/build/hash.js +19 -0
- package/build/hash.js.map +1 -0
- package/build/index.d.ts +9 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +8 -0
- package/build/index.js.map +1 -0
- package/build/resource.d.ts +14 -0
- package/build/resource.d.ts.map +1 -0
- package/build/resource.js +23 -0
- package/build/resource.js.map +1 -0
- package/build/types.d.ts +36 -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 +48 -0
- package/src/append.ts +34 -0
- package/src/coguard.ts +23 -0
- package/src/consts.ts +2 -0
- package/src/guard.ts +143 -0
- package/src/handlers/index.ts +115 -0
- package/src/hash.ts +27 -0
- package/src/index.ts +8 -0
- package/src/resource.ts +28 -0
- package/src/types.ts +39 -0
- package/tests/coguard.spec.ts +36 -0
- package/tests/context.ts +109 -0
- package/tests/guard.spec.ts +128 -0
- package/tests/handlers.spec.ts +154 -0
- package/tests/tsconfig.json +12 -0
- package/tsconfig.json +11 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,aAAa,CAAA;AAC3B,cAAc,eAAe,CAAA;AAC7B,cAAc,YAAY,CAAA;AAC1B,cAAc,WAAW,CAAA;AACzB,cAAc,cAAc,CAAA;AAC5B,cAAc,aAAa,CAAA;AAC3B,cAAc,qBAAqB,CAAA;AACnC,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,eAAe,CAAA;AAC7B,cAAc,YAAY,CAAA;AAC1B,cAAc,WAAW,CAAA;AACzB,cAAc,cAAc,CAAA;AAC5B,cAAc,aAAa,CAAA;AAC3B,cAAc,qBAAqB,CAAA"}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { AccessTokenResource } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Where access tokens live.
|
|
4
|
+
*
|
|
5
|
+
* `entityId` and `profileId` are stored as plain strings and only indexed — never declared as
|
|
6
|
+
* ObjectId references — because that is how the identity records they point at store them, and a
|
|
7
|
+
* reference declared on one side of a join and not the other finds nothing.
|
|
8
|
+
*
|
|
9
|
+
* No `$jsonSchema` is applied. The write path is one handler with a validated body, and a schema
|
|
10
|
+
* on the collection would have to be kept in step with a record the guard writes to on every
|
|
11
|
+
* request (`lastUsedAt`) for no protection the handler does not already give.
|
|
12
|
+
*/
|
|
13
|
+
export declare const makeAccessTokenResource: (dbAlias?: string) => AccessTokenResource;
|
|
14
|
+
//# sourceMappingURL=resource.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"resource.d.ts","sourceRoot":"","sources":["../src/resource.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,YAAY,CAAA;AAErD;;;;;;;;;;GAUG;AACH,eAAO,MAAM,uBAAuB,aAAc,MAAM,KAAG,mBAW1D,CAAA"}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { AUTH_TOKEN_COLLECTION, AUTH_TOKEN_RESOURCE } from '@owlmeans/auth-token';
|
|
2
|
+
import { makeMongoResource } from '@owlmeans/mongo-resource';
|
|
3
|
+
/**
|
|
4
|
+
* Where access tokens live.
|
|
5
|
+
*
|
|
6
|
+
* `entityId` and `profileId` are stored as plain strings and only indexed — never declared as
|
|
7
|
+
* ObjectId references — because that is how the identity records they point at store them, and a
|
|
8
|
+
* reference declared on one side of a join and not the other finds nothing.
|
|
9
|
+
*
|
|
10
|
+
* No `$jsonSchema` is applied. The write path is one handler with a validated body, and a schema
|
|
11
|
+
* on the collection would have to be kept in step with a record the guard writes to on every
|
|
12
|
+
* request (`lastUsedAt`) for no protection the handler does not already give.
|
|
13
|
+
*/
|
|
14
|
+
export const makeAccessTokenResource = (dbAlias) => {
|
|
15
|
+
const resource = makeMongoResource(AUTH_TOKEN_RESOURCE, dbAlias, undefined, AUTH_TOKEN_COLLECTION);
|
|
16
|
+
// The lookup every authenticated request makes. Unique because two records answering one
|
|
17
|
+
// presented secret would make revocation a coin toss.
|
|
18
|
+
resource.index('hash', { hash: 1 }, { unique: true });
|
|
19
|
+
resource.index('owner', { entityId: 1, profileId: 1 });
|
|
20
|
+
resource.index('expiresAt', { expiresAt: 1 }, { sparse: true });
|
|
21
|
+
return resource;
|
|
22
|
+
};
|
|
23
|
+
//# sourceMappingURL=resource.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"resource.js","sourceRoot":"","sources":["../src/resource.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,qBAAqB,EAAE,mBAAmB,EAAE,MAAM,sBAAsB,CAAA;AAEjF,OAAO,EAAE,iBAAiB,EAAE,MAAM,0BAA0B,CAAA;AAG5D;;;;;;;;;;GAUG;AACH,MAAM,CAAC,MAAM,uBAAuB,GAAG,CAAC,OAAgB,EAAuB,EAAE;IAC/E,MAAM,QAAQ,GAAG,iBAAiB,CAChC,mBAAmB,EAAE,OAAO,EAAE,SAAS,EAAE,qBAAqB,CAC/D,CAAA;IACD,yFAAyF;IACzF,sDAAsD;IACtD,QAAQ,CAAC,KAAK,CAAC,MAAM,EAAE,EAAE,IAAI,EAAE,CAAC,EAAE,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,CAAA;IACrD,QAAQ,CAAC,KAAK,CAAC,OAAO,EAAE,EAAE,QAAQ,EAAE,CAAC,EAAE,SAAS,EAAE,CAAC,EAAE,CAAC,CAAA;IACtD,QAAQ,CAAC,KAAK,CAAC,WAAW,EAAE,EAAE,SAAS,EAAE,CAAC,EAAE,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,CAAA;IAE/D,OAAO,QAAQ,CAAA;AACjB,CAAC,CAAA"}
|
package/build/types.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { AccessTokenRecord } from '@owlmeans/auth-token';
|
|
2
|
+
import type { MongoResource } from '@owlmeans/mongo-resource';
|
|
3
|
+
import type { ServerConfig, ServerContext } from '@owlmeans/server-context';
|
|
4
|
+
export type AccessTokenResource = MongoResource<AccessTokenRecord>;
|
|
5
|
+
export interface AuthTokenGuardOptions {
|
|
6
|
+
/**
|
|
7
|
+
* The prefix every token this deployment issues carries.
|
|
8
|
+
*
|
|
9
|
+
* Also what the guard claims on: a `Bearer` value without it is left to whatever other guard
|
|
10
|
+
* the route declares.
|
|
11
|
+
*/
|
|
12
|
+
prefix?: string;
|
|
13
|
+
/**
|
|
14
|
+
* Routes this guard refuses even when the entrypoint declares it.
|
|
15
|
+
*
|
|
16
|
+
* A guard added as a coguard reaches every child of the base it was added to, and a child
|
|
17
|
+
* cannot drop an inherited guard — so a route that must stay behind an interactive session
|
|
18
|
+
* (opening a checkout, starting an OAuth flow, minting another token) is named here. Refusing
|
|
19
|
+
* in `match` rather than in the handler is what makes the refusal a 401 rather than a route
|
|
20
|
+
* that quietly works.
|
|
21
|
+
*/
|
|
22
|
+
denyAliases?: string[];
|
|
23
|
+
/** How often `lastUsedAt` may be written. Defaults to the package constant. */
|
|
24
|
+
touchInterval?: number;
|
|
25
|
+
/** Resource alias holding the token records. */
|
|
26
|
+
resourceAlias?: string;
|
|
27
|
+
/** Resource alias holding the identity profiles a token is bound to. */
|
|
28
|
+
profileAlias?: string;
|
|
29
|
+
}
|
|
30
|
+
export interface AuthTokenConfig extends ServerConfig {
|
|
31
|
+
authToken?: {
|
|
32
|
+
prefix?: string;
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
export type AuthTokenContext<C extends AuthTokenConfig = AuthTokenConfig> = ServerContext<C>;
|
|
36
|
+
//# 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,iBAAiB,EAAE,MAAM,sBAAsB,CAAA;AAC7D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,0BAA0B,CAAA;AAC7D,OAAO,KAAK,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,0BAA0B,CAAA;AAE3E,MAAM,MAAM,mBAAmB,GAAG,aAAa,CAAC,iBAAiB,CAAC,CAAA;AAElE,MAAM,WAAW,qBAAqB;IACpC;;;;;OAKG;IACH,MAAM,CAAC,EAAE,MAAM,CAAA;IACf;;;;;;;;OAQG;IACH,WAAW,CAAC,EAAE,MAAM,EAAE,CAAA;IACtB,+EAA+E;IAC/E,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,gDAAgD;IAChD,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,wEAAwE;IACxE,YAAY,CAAC,EAAE,MAAM,CAAA;CACtB;AAED,MAAM,WAAW,eAAgB,SAAQ,YAAY;IACnD,SAAS,CAAC,EAAE;QACV,MAAM,CAAC,EAAE,MAAM,CAAA;KAChB,CAAA;CACF;AAED,MAAM,MAAM,gBAAgB,CAAC,CAAC,SAAS,eAAe,GAAG,eAAe,IAAI,aAAa,CAAC,CAAC,CAAC,CAAA"}
|
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,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@owlmeans/server-auth-token",
|
|
3
|
+
"version": "0.1.18-rc.1",
|
|
4
|
+
"license": "MIT",
|
|
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
|
+
"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
|
+
"@noble/hashes": "^1.5.0",
|
|
27
|
+
"@owlmeans/auth": "^0.1.18-rc.10",
|
|
28
|
+
"@owlmeans/auth-common": "^0.1.18-rc.14",
|
|
29
|
+
"@owlmeans/auth-token": "^0.1.18-rc.1",
|
|
30
|
+
"@owlmeans/context": "^0.1.18-rc.9",
|
|
31
|
+
"@owlmeans/entrypoint": "^0.1.18-rc.12",
|
|
32
|
+
"@owlmeans/mongo-resource": "^0.1.18-rc.13",
|
|
33
|
+
"@owlmeans/resource": "^0.1.18-rc.10",
|
|
34
|
+
"@owlmeans/server-api": "^0.1.18-rc.18",
|
|
35
|
+
"@owlmeans/server-auth-identity": "^0.1.18-rc.14",
|
|
36
|
+
"@owlmeans/server-context": "^0.1.18-rc.13",
|
|
37
|
+
"@scure/base": "^1.1.9"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@owlmeans/dep-config": "workspace:*",
|
|
41
|
+
"@types/bun": "^1.4.0",
|
|
42
|
+
"nodemon": "^3.1.14",
|
|
43
|
+
"typescript": "^7.0.2"
|
|
44
|
+
},
|
|
45
|
+
"publishConfig": {
|
|
46
|
+
"access": "public"
|
|
47
|
+
}
|
|
48
|
+
}
|
package/src/append.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { GUARD_AUTH_TOKEN } from '@owlmeans/auth-token'
|
|
2
|
+
import type { BasicContext } from '@owlmeans/context'
|
|
3
|
+
import { makeAuthTokenGuard } from './guard.js'
|
|
4
|
+
import { makeAccessTokenResource } from './resource.js'
|
|
5
|
+
import type { AuthTokenConfig, AuthTokenGuardOptions } from './types.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Register the token store.
|
|
9
|
+
*
|
|
10
|
+
* Pass the same `dbAlias` the identity resources use: a token's `entityId`/`profileId` are read
|
|
11
|
+
* back against those collections on every request, and splitting the two across databases makes
|
|
12
|
+
* every lookup a cross-database join nobody wrote.
|
|
13
|
+
*/
|
|
14
|
+
export const appendAuthTokenResources = (context: BasicContext<any>, dbAlias?: string): void => {
|
|
15
|
+
context.registerResource(makeAccessTokenResource(dbAlias))
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Register the guard that verifies a presented access token.
|
|
20
|
+
*
|
|
21
|
+
* A `prefix` passed here is written into the configuration, because the guard is not the only
|
|
22
|
+
* reader: the minting handler has no guard instance to ask, and the two MUST agree — a deployment
|
|
23
|
+
* that told only the guard would issue `owl_…` tokens its own guard refuses to claim, and every
|
|
24
|
+
* request would 401 with nothing anywhere saying why.
|
|
25
|
+
*/
|
|
26
|
+
export const appendAuthTokenGuard = (
|
|
27
|
+
context: BasicContext<any>, alias: string = GUARD_AUTH_TOKEN, opts?: AuthTokenGuardOptions
|
|
28
|
+
): void => {
|
|
29
|
+
if (opts?.prefix != null) {
|
|
30
|
+
const cfg = context.cfg as AuthTokenConfig
|
|
31
|
+
cfg.authToken = { ...cfg.authToken, prefix: opts.prefix }
|
|
32
|
+
}
|
|
33
|
+
context.registerService(makeAuthTokenGuard(alias, opts))
|
|
34
|
+
}
|
package/src/coguard.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { GUARD_AUTH_TOKEN } from '@owlmeans/auth-token'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Let an access token authenticate every route that already has a guard.
|
|
5
|
+
*
|
|
6
|
+
* An API client drives the same surface a browser does — projects, stories, files — so admitting
|
|
7
|
+
* the token only on a dedicated prefix would mean a second copy of every route. The coguard is
|
|
8
|
+
* appended, never substituted: the primary guard stays first and still claims its own credential,
|
|
9
|
+
* and this one matches only a value carrying the deployment's token prefix.
|
|
10
|
+
*
|
|
11
|
+
* Routes that must stay behind an interactive session are named in the GUARD's deny list rather
|
|
12
|
+
* than skipped here — a child entrypoint inherits every guard its ancestors declare and cannot
|
|
13
|
+
* drop one, so the refusal has to live where the credential is inspected.
|
|
14
|
+
*/
|
|
15
|
+
export const setupAuthTokenCoguard = (
|
|
16
|
+
entrypoints: Array<{ guards?: string[] }>, guard: string = GUARD_AUTH_TOKEN
|
|
17
|
+
): void => {
|
|
18
|
+
entrypoints.forEach(entrypoint => {
|
|
19
|
+
if (entrypoint.guards == null || entrypoint.guards.length < 1) return
|
|
20
|
+
if (entrypoint.guards.includes(guard)) return
|
|
21
|
+
entrypoint.guards = [...entrypoint.guards, guard]
|
|
22
|
+
})
|
|
23
|
+
}
|
package/src/consts.ts
ADDED
package/src/guard.ts
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import type { Auth } from '@owlmeans/auth'
|
|
2
|
+
import { ALL_SCOPES, AuthroizationType } from '@owlmeans/auth'
|
|
3
|
+
import {
|
|
4
|
+
AUTH_TOKEN_DEFAULT_PREFIX, AUTH_TOKEN_SCHEME, AUTH_TOKEN_RESOURCE, AUTH_TOKEN_TOUCH_INTERVAL,
|
|
5
|
+
BEARER_SCHEME, GUARD_AUTH_TOKEN, isAccessToken, parseAuthorizationHeader
|
|
6
|
+
} from '@owlmeans/auth-token'
|
|
7
|
+
import { AUTH_HEADER } from '@owlmeans/auth'
|
|
8
|
+
import { ENTITY_RESOLVER } from '@owlmeans/auth-common'
|
|
9
|
+
import type { EntityResolverService } from '@owlmeans/auth-common'
|
|
10
|
+
import { createService } from '@owlmeans/context'
|
|
11
|
+
import type { AbstractRequest, AbstractResponse, GuardService } from '@owlmeans/entrypoint'
|
|
12
|
+
import { AUTH_IDENTITY_PROFILE } from '@owlmeans/server-auth-identity'
|
|
13
|
+
import type { IdentityProfile, IdentityProfileResource } from '@owlmeans/server-auth-identity'
|
|
14
|
+
import { hashAccessToken } from './hash.js'
|
|
15
|
+
import type { AccessTokenResource, AuthTokenConfig, AuthTokenContext, AuthTokenGuardOptions } from './types.js'
|
|
16
|
+
|
|
17
|
+
/** Where the deployment's prefix comes from: the guard's own options, then config, then default. */
|
|
18
|
+
export const prefixOf = (context: AuthTokenContext, opts?: AuthTokenGuardOptions): string =>
|
|
19
|
+
opts?.prefix ?? (context.cfg as AuthTokenConfig).authToken?.prefix ?? AUTH_TOKEN_DEFAULT_PREFIX
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The guard that verifies a long-lived access token.
|
|
23
|
+
*
|
|
24
|
+
* It is a GUARD, not an authentication method: nothing is exchanged, no envelope is signed, no
|
|
25
|
+
* session begins. A caller presents a credential it was handed once, and the guard turns it into
|
|
26
|
+
* the same `Auth` payload every other guard produces — so ownership gates, entitlement gates and
|
|
27
|
+
* handlers written against a browser session work unchanged for an API client.
|
|
28
|
+
*
|
|
29
|
+
* Three properties are what keep it from colliding with the guards it sits beside:
|
|
30
|
+
*
|
|
31
|
+
* - it claims a header only when the value carries the deployment's prefix, so an OwlMeans
|
|
32
|
+
* `ED25519-BASIC-TOKEN` bearer, an OIDC one, and this one can all arrive under `Authorization`
|
|
33
|
+
* without any of them shadowing the others;
|
|
34
|
+
* - it accepts the OwlMeans `AUTH-TOKEN` scheme AND plain `Bearer`, because a third-party client
|
|
35
|
+
* configured with a URL sends the latter whatever the documentation says;
|
|
36
|
+
* - it intersects the token's scopes with its profile's on every request, so a token can never
|
|
37
|
+
* outlive or outrank the person who minted it.
|
|
38
|
+
*/
|
|
39
|
+
export const makeAuthTokenGuard = (
|
|
40
|
+
alias: string = GUARD_AUTH_TOKEN, opts: AuthTokenGuardOptions = {}
|
|
41
|
+
): GuardService => {
|
|
42
|
+
const touchInterval = opts.touchInterval ?? AUTH_TOKEN_TOUCH_INTERVAL
|
|
43
|
+
|
|
44
|
+
const present = (req: Partial<AbstractRequest>, prefix: string): string | null => {
|
|
45
|
+
const parsed = parseAuthorizationHeader(req.headers?.[AUTH_HEADER])
|
|
46
|
+
if (parsed == null) return null
|
|
47
|
+
if (parsed.scheme !== AUTH_TOKEN_SCHEME && parsed.scheme !== BEARER_SCHEME) return null
|
|
48
|
+
|
|
49
|
+
return isAccessToken(parsed.value, prefix) ? parsed.value : null
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const service: GuardService = createService<GuardService>(alias, {
|
|
53
|
+
match: async req => {
|
|
54
|
+
const context = service.assertCtx<AuthTokenConfig, AuthTokenContext>()
|
|
55
|
+
if (req == null) return false
|
|
56
|
+
// A denied route refuses the credential outright. The refusal has to happen here rather
|
|
57
|
+
// than in the handler: a guard appended to a base reaches every child, and a child cannot
|
|
58
|
+
// drop what it inherited — so the only place left to say "not with this credential" is the
|
|
59
|
+
// moment the guard decides whether the request is its business at all.
|
|
60
|
+
if (opts.denyAliases != null && req.alias != null && opts.denyAliases.includes(req.alias)) {
|
|
61
|
+
return false
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return present(req, prefixOf(context, opts)) != null
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
handle: async <T>(req: AbstractRequest, res: AbstractResponse<Auth>) => {
|
|
68
|
+
const context = service.assertCtx<AuthTokenConfig, AuthTokenContext>()
|
|
69
|
+
const prefix = prefixOf(context, opts)
|
|
70
|
+
const presented = present(req, prefix)
|
|
71
|
+
if (presented == null) return false as T
|
|
72
|
+
|
|
73
|
+
const tokens = context.resource<AccessTokenResource>(opts.resourceAlias ?? AUTH_TOKEN_RESOURCE)
|
|
74
|
+
const record = await tokens.load({ hash: hashAccessToken(presented) })
|
|
75
|
+
if (record == null) return false as T
|
|
76
|
+
if (record.revokedAt != null) return false as T
|
|
77
|
+
if (record.expiresAt != null && new Date(record.expiresAt) < new Date()) return false as T
|
|
78
|
+
|
|
79
|
+
// A token is only ever as good as the profile behind it. Reading the profile per request is
|
|
80
|
+
// what makes revoking a person's access revoke every token they ever minted, without any
|
|
81
|
+
// token record being touched.
|
|
82
|
+
const profiles = context.resource<IdentityProfileResource>(
|
|
83
|
+
opts.profileAlias ?? AUTH_IDENTITY_PROFILE
|
|
84
|
+
)
|
|
85
|
+
let profile: IdentityProfile | null = null
|
|
86
|
+
try {
|
|
87
|
+
profile = await profiles.load({ entityId: record.entityId, profileId: record.profileId })
|
|
88
|
+
} catch {
|
|
89
|
+
return false as T
|
|
90
|
+
}
|
|
91
|
+
if (profile == null) return false as T
|
|
92
|
+
if (profile.expiresAt != null && new Date(profile.expiresAt) < new Date()) return false as T
|
|
93
|
+
|
|
94
|
+
const profileScopes = profile.scopes ?? []
|
|
95
|
+
const scopes = profileScopes.includes(ALL_SCOPES)
|
|
96
|
+
? record.scopes
|
|
97
|
+
: record.scopes.includes(ALL_SCOPES)
|
|
98
|
+
? profileScopes
|
|
99
|
+
: record.scopes.filter(scope => profileScopes.includes(scope))
|
|
100
|
+
|
|
101
|
+
// `attachEntity` canonicalizes whatever it is given, but it resolves by NAME — so writing
|
|
102
|
+
// the current slug here costs one cached read and keeps the payload identical to the one a
|
|
103
|
+
// browser session produces.
|
|
104
|
+
let entitySlug = record.entityId
|
|
105
|
+
if (context.hasService(ENTITY_RESOLVER)) {
|
|
106
|
+
const resolver = context.service<EntityResolverService>(ENTITY_RESOLVER)
|
|
107
|
+
const entity = await resolver.byId(record.entityId)
|
|
108
|
+
if (entity != null) entitySlug = entity.slug
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
res.resolve({
|
|
112
|
+
// Never the secret: what a downstream reader sees is the display form, which is what a
|
|
113
|
+
// log or an audit trail can safely carry.
|
|
114
|
+
token: record.display,
|
|
115
|
+
type: AuthroizationType.AuthToken,
|
|
116
|
+
role: profile.role,
|
|
117
|
+
userId: record.userId,
|
|
118
|
+
profileId: record.profileId,
|
|
119
|
+
entitySlug,
|
|
120
|
+
scopes,
|
|
121
|
+
isUser: true,
|
|
122
|
+
source: context.cfg.service,
|
|
123
|
+
createdAt: new Date(record.createdAt),
|
|
124
|
+
...(record.expiresAt != null ? { expiresAt: new Date(record.expiresAt) } : {}),
|
|
125
|
+
} as Auth)
|
|
126
|
+
|
|
127
|
+
const last = record.lastUsedAt == null ? 0 : new Date(record.lastUsedAt).getTime()
|
|
128
|
+
if (Date.now() - last > touchInterval) {
|
|
129
|
+
// Fire and forget: a usage timestamp is never a reason to fail a request, and awaiting it
|
|
130
|
+
// would put a database write on the critical path of every authenticated call.
|
|
131
|
+
void tokens.save({ ...record, lastUsedAt: new Date() })
|
|
132
|
+
.catch((e: unknown) => console.error('auth-token: lastUsedAt', e))
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return true as T
|
|
136
|
+
},
|
|
137
|
+
|
|
138
|
+
/** Server-side guard: it verifies what arrives, it never produces a credential. */
|
|
139
|
+
authenticated: async () => null,
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
return service
|
|
143
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { AuthForbidden, AuthroizationType, ALL_SCOPES, AuthRole } from '@owlmeans/auth'
|
|
2
|
+
import {
|
|
3
|
+
AUTH_TOKEN_MAX_TTL, AUTH_TOKEN_RESOURCE
|
|
4
|
+
} from '@owlmeans/auth-token'
|
|
5
|
+
import type {
|
|
6
|
+
AccessTokenList, AccessTokenParams, AccessTokenRecord, AccessTokenView, CreateAccessToken,
|
|
7
|
+
IssuedAccessToken
|
|
8
|
+
} from '@owlmeans/auth-token'
|
|
9
|
+
import { requireEntityKey } from '@owlmeans/auth-common'
|
|
10
|
+
import { handleBody, handleParams, handleRequest } from '@owlmeans/server-api'
|
|
11
|
+
import { mintAccessToken } from '../hash.js'
|
|
12
|
+
import { prefixOf } from '../guard.js'
|
|
13
|
+
import type { AccessTokenResource, AuthTokenContext } from '../types.js'
|
|
14
|
+
|
|
15
|
+
const view = (record: AccessTokenRecord): AccessTokenView => {
|
|
16
|
+
const { hash, ...rest } = record
|
|
17
|
+
|
|
18
|
+
return rest
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const tokens = (ctx: AuthTokenContext): AccessTokenResource =>
|
|
22
|
+
ctx.resource<AccessTokenResource>(AUTH_TOKEN_RESOURCE)
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A token may never mint another token.
|
|
26
|
+
*
|
|
27
|
+
* Minting is the one operation that turns a stolen credential into a permanent one: a token that
|
|
28
|
+
* can create tokens survives the revocation of the token that leaked. Creating and revoking
|
|
29
|
+
* therefore require a credential a person produced in a browser — which is also why the deployment
|
|
30
|
+
* names these routes in the guard's deny list, so the refusal is a 401 at the boundary rather than
|
|
31
|
+
* a check every future handler has to remember.
|
|
32
|
+
*/
|
|
33
|
+
const refuseTokenAuth = (req: { auth?: { type?: string } }, what: string): void => {
|
|
34
|
+
if (req.auth?.type === AuthroizationType.AuthToken) {
|
|
35
|
+
throw new AuthForbidden(what)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const listAccessTokens = handleRequest(async (req, context) => {
|
|
40
|
+
const ctx = context as AuthTokenContext
|
|
41
|
+
const entityId = requireEntityKey(req)
|
|
42
|
+
const profileId = req.auth?.profileId
|
|
43
|
+
if (profileId == null) throw new AuthForbidden('profile')
|
|
44
|
+
|
|
45
|
+
const result = await tokens(ctx).list({ entityId, profileId }, { size: 0 })
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
items: result.items
|
|
49
|
+
.map(view)
|
|
50
|
+
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()),
|
|
51
|
+
} satisfies AccessTokenList
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
export const createAccessToken = handleBody<CreateAccessToken>(async (payload, context, req) => {
|
|
55
|
+
const ctx = context as AuthTokenContext
|
|
56
|
+
refuseTokenAuth(req, 'token-mint')
|
|
57
|
+
|
|
58
|
+
const entityId = requireEntityKey(req)
|
|
59
|
+
const profileId = req.auth?.profileId
|
|
60
|
+
const userId = req.auth?.userId
|
|
61
|
+
if (profileId == null || userId == null) throw new AuthForbidden('profile')
|
|
62
|
+
|
|
63
|
+
const own = req.auth?.scopes ?? []
|
|
64
|
+
const scopes = payload.scopes ?? own
|
|
65
|
+
// A token is a delegation, so it can only ever narrow. Asking for a scope the caller does not
|
|
66
|
+
// hold is refused rather than silently dropped: a token that quietly grants less than it was
|
|
67
|
+
// asked for fails later, somewhere else, with an error about the wrong thing.
|
|
68
|
+
if (!own.includes(ALL_SCOPES) && scopes.some(scope => !own.includes(scope))) {
|
|
69
|
+
throw new AuthForbidden('scope')
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// The SAME resolver the guard uses, so a minted token is one this deployment's guard claims.
|
|
73
|
+
const minted = mintAccessToken(prefixOf(ctx))
|
|
74
|
+
const now = new Date()
|
|
75
|
+
const record = await tokens(ctx).create({
|
|
76
|
+
hash: minted.hash,
|
|
77
|
+
display: minted.display,
|
|
78
|
+
name: payload.name,
|
|
79
|
+
userId,
|
|
80
|
+
profileId,
|
|
81
|
+
entityId,
|
|
82
|
+
scopes,
|
|
83
|
+
role: req.auth?.role ?? AuthRole.User,
|
|
84
|
+
createdAt: now,
|
|
85
|
+
...(payload.expiresIn != null
|
|
86
|
+
? { expiresAt: new Date(now.getTime() + Math.min(payload.expiresIn * 1000, AUTH_TOKEN_MAX_TTL)) }
|
|
87
|
+
: {}),
|
|
88
|
+
} as AccessTokenRecord)
|
|
89
|
+
|
|
90
|
+
// The only moment the plaintext exists outside the caller's own machine.
|
|
91
|
+
return { token: minted.token, record: view(record) } satisfies IssuedAccessToken
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
export const revokeAccessToken = handleParams<AccessTokenParams>(async (payload, context, req) => {
|
|
95
|
+
const ctx = context as AuthTokenContext
|
|
96
|
+
refuseTokenAuth(req, 'token-revoke')
|
|
97
|
+
|
|
98
|
+
const entityId = requireEntityKey(req)
|
|
99
|
+
const profileId = req.auth?.profileId
|
|
100
|
+
if (profileId == null) throw new AuthForbidden('profile')
|
|
101
|
+
|
|
102
|
+
const record = await tokens(ctx).load(payload.id)
|
|
103
|
+
// A token of another profile answers exactly as an unknown id does — an owner learns nothing
|
|
104
|
+
// about tokens that are not theirs, not even that one exists.
|
|
105
|
+
if (record == null || record.entityId !== entityId || record.profileId !== profileId) {
|
|
106
|
+
throw new AuthForbidden('token')
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Idempotent: revoking twice is what a retry looks like.
|
|
110
|
+
if (record.revokedAt == null) {
|
|
111
|
+
await tokens(ctx).save({ ...record, revokedAt: new Date(), updatedAt: new Date() })
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return { id: payload.id }
|
|
115
|
+
})
|
package/src/hash.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { AUTH_TOKEN_SECRET_BYTES, displayOf } from '@owlmeans/auth-token'
|
|
2
|
+
import { base58 } from '@scure/base'
|
|
3
|
+
import { sha256 } from '@noble/hashes/sha256'
|
|
4
|
+
import { bytesToHex, randomBytes } from '@noble/hashes/utils'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The stored form of a token.
|
|
8
|
+
*
|
|
9
|
+
* SHA-256 rather than a password hash on purpose: the input is 192 bits of machine-generated
|
|
10
|
+
* randomness, so there is no dictionary to slow down, and the hash is computed on every single
|
|
11
|
+
* API request — a deliberately slow function here would be a deliberate rate limit on the
|
|
12
|
+
* deployment's whole authenticated surface.
|
|
13
|
+
*/
|
|
14
|
+
export const hashAccessToken = (token: string): string => bytesToHex(sha256(new TextEncoder().encode(token)))
|
|
15
|
+
|
|
16
|
+
export interface MintedToken {
|
|
17
|
+
token: string
|
|
18
|
+
hash: string
|
|
19
|
+
display: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Mint one token: the plaintext (returned once), its hash (stored) and its display form. */
|
|
23
|
+
export const mintAccessToken = (prefix: string): MintedToken => {
|
|
24
|
+
const token = `${prefix}${base58.encode(randomBytes(AUTH_TOKEN_SECRET_BYTES))}`
|
|
25
|
+
|
|
26
|
+
return { token, hash: hashAccessToken(token), display: displayOf(token, prefix) }
|
|
27
|
+
}
|
package/src/index.ts
ADDED
package/src/resource.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { AUTH_TOKEN_COLLECTION, AUTH_TOKEN_RESOURCE } from '@owlmeans/auth-token'
|
|
2
|
+
import type { AccessTokenRecord } from '@owlmeans/auth-token'
|
|
3
|
+
import { makeMongoResource } from '@owlmeans/mongo-resource'
|
|
4
|
+
import type { AccessTokenResource } from './types.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Where access tokens live.
|
|
8
|
+
*
|
|
9
|
+
* `entityId` and `profileId` are stored as plain strings and only indexed — never declared as
|
|
10
|
+
* ObjectId references — because that is how the identity records they point at store them, and a
|
|
11
|
+
* reference declared on one side of a join and not the other finds nothing.
|
|
12
|
+
*
|
|
13
|
+
* No `$jsonSchema` is applied. The write path is one handler with a validated body, and a schema
|
|
14
|
+
* on the collection would have to be kept in step with a record the guard writes to on every
|
|
15
|
+
* request (`lastUsedAt`) for no protection the handler does not already give.
|
|
16
|
+
*/
|
|
17
|
+
export const makeAccessTokenResource = (dbAlias?: string): AccessTokenResource => {
|
|
18
|
+
const resource = makeMongoResource<AccessTokenRecord, AccessTokenResource>(
|
|
19
|
+
AUTH_TOKEN_RESOURCE, dbAlias, undefined, AUTH_TOKEN_COLLECTION
|
|
20
|
+
)
|
|
21
|
+
// The lookup every authenticated request makes. Unique because two records answering one
|
|
22
|
+
// presented secret would make revocation a coin toss.
|
|
23
|
+
resource.index('hash', { hash: 1 }, { unique: true })
|
|
24
|
+
resource.index('owner', { entityId: 1, profileId: 1 })
|
|
25
|
+
resource.index('expiresAt', { expiresAt: 1 }, { sparse: true })
|
|
26
|
+
|
|
27
|
+
return resource
|
|
28
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { AccessTokenRecord } from '@owlmeans/auth-token'
|
|
2
|
+
import type { MongoResource } from '@owlmeans/mongo-resource'
|
|
3
|
+
import type { ServerConfig, ServerContext } from '@owlmeans/server-context'
|
|
4
|
+
|
|
5
|
+
export type AccessTokenResource = MongoResource<AccessTokenRecord>
|
|
6
|
+
|
|
7
|
+
export interface AuthTokenGuardOptions {
|
|
8
|
+
/**
|
|
9
|
+
* The prefix every token this deployment issues carries.
|
|
10
|
+
*
|
|
11
|
+
* Also what the guard claims on: a `Bearer` value without it is left to whatever other guard
|
|
12
|
+
* the route declares.
|
|
13
|
+
*/
|
|
14
|
+
prefix?: string
|
|
15
|
+
/**
|
|
16
|
+
* Routes this guard refuses even when the entrypoint declares it.
|
|
17
|
+
*
|
|
18
|
+
* A guard added as a coguard reaches every child of the base it was added to, and a child
|
|
19
|
+
* cannot drop an inherited guard — so a route that must stay behind an interactive session
|
|
20
|
+
* (opening a checkout, starting an OAuth flow, minting another token) is named here. Refusing
|
|
21
|
+
* in `match` rather than in the handler is what makes the refusal a 401 rather than a route
|
|
22
|
+
* that quietly works.
|
|
23
|
+
*/
|
|
24
|
+
denyAliases?: string[]
|
|
25
|
+
/** How often `lastUsedAt` may be written. Defaults to the package constant. */
|
|
26
|
+
touchInterval?: number
|
|
27
|
+
/** Resource alias holding the token records. */
|
|
28
|
+
resourceAlias?: string
|
|
29
|
+
/** Resource alias holding the identity profiles a token is bound to. */
|
|
30
|
+
profileAlias?: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface AuthTokenConfig extends ServerConfig {
|
|
34
|
+
authToken?: {
|
|
35
|
+
prefix?: string
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type AuthTokenContext<C extends AuthTokenConfig = AuthTokenConfig> = ServerContext<C>
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { GUARD_AUTH_TOKEN } from '@owlmeans/auth-token'
|
|
3
|
+
import { setupAuthTokenCoguard } from '../src/coguard.js'
|
|
4
|
+
|
|
5
|
+
describe('@owlmeans/server-auth-token — the coguard', () => {
|
|
6
|
+
test('appends to a guarded entrypoint and leaves the primary guard first', () => {
|
|
7
|
+
const entrypoints = [{ guards: ['auth'] }]
|
|
8
|
+
setupAuthTokenCoguard(entrypoints)
|
|
9
|
+
|
|
10
|
+
expect(entrypoints[0].guards).toEqual(['auth', GUARD_AUTH_TOKEN])
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
test('leaves an unguarded entrypoint unguarded', () => {
|
|
14
|
+
// A public route stays public: adding a guard here would turn an open page into a 401.
|
|
15
|
+
const entrypoints: Array<{ guards?: string[] }> = [{}, { guards: [] }]
|
|
16
|
+
setupAuthTokenCoguard(entrypoints)
|
|
17
|
+
|
|
18
|
+
expect(entrypoints[0].guards).toBeUndefined()
|
|
19
|
+
expect(entrypoints[1].guards).toEqual([])
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test('is idempotent — a second pass adds nothing', () => {
|
|
23
|
+
const entrypoints = [{ guards: ['auth'] }]
|
|
24
|
+
setupAuthTokenCoguard(entrypoints)
|
|
25
|
+
setupAuthTokenCoguard(entrypoints)
|
|
26
|
+
|
|
27
|
+
expect(entrypoints[0].guards).toEqual(['auth', GUARD_AUTH_TOKEN])
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
test('honours a custom guard alias', () => {
|
|
31
|
+
const entrypoints = [{ guards: ['auth'] }]
|
|
32
|
+
setupAuthTokenCoguard(entrypoints, 'other-guard')
|
|
33
|
+
|
|
34
|
+
expect(entrypoints[0].guards).toEqual(['auth', 'other-guard'])
|
|
35
|
+
})
|
|
36
|
+
})
|