@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.
Files changed (55) hide show
  1. package/README.md +15 -0
  2. package/agent-meta/manifest.json +16 -0
  3. package/agent-meta/skills/server-auth-token/SKILL.md +136 -0
  4. package/build/append.d.ts +20 -0
  5. package/build/append.d.ts.map +1 -0
  6. package/build/append.js +29 -0
  7. package/build/append.js.map +1 -0
  8. package/build/coguard.d.ts +16 -0
  9. package/build/coguard.d.ts.map +1 -0
  10. package/build/coguard.js +23 -0
  11. package/build/coguard.js.map +1 -0
  12. package/build/consts.d.ts +3 -0
  13. package/build/consts.d.ts.map +1 -0
  14. package/build/consts.js +3 -0
  15. package/build/consts.js.map +1 -0
  16. package/build/guard.d.ts +24 -0
  17. package/build/guard.d.ts.map +1 -0
  18. package/build/guard.js +126 -0
  19. package/build/guard.js.map +1 -0
  20. package/build/handlers/index.d.ts +4 -0
  21. package/build/handlers/index.d.ts.map +1 -0
  22. package/build/handlers/index.js +94 -0
  23. package/build/handlers/index.js.map +1 -0
  24. package/build/hash.d.ts +17 -0
  25. package/build/hash.d.ts.map +1 -0
  26. package/build/hash.js +19 -0
  27. package/build/hash.js.map +1 -0
  28. package/build/index.d.ts +9 -0
  29. package/build/index.d.ts.map +1 -0
  30. package/build/index.js +8 -0
  31. package/build/index.js.map +1 -0
  32. package/build/resource.d.ts +14 -0
  33. package/build/resource.d.ts.map +1 -0
  34. package/build/resource.js +23 -0
  35. package/build/resource.js.map +1 -0
  36. package/build/types.d.ts +36 -0
  37. package/build/types.d.ts.map +1 -0
  38. package/build/types.js +2 -0
  39. package/build/types.js.map +1 -0
  40. package/package.json +48 -0
  41. package/src/append.ts +34 -0
  42. package/src/coguard.ts +23 -0
  43. package/src/consts.ts +2 -0
  44. package/src/guard.ts +143 -0
  45. package/src/handlers/index.ts +115 -0
  46. package/src/hash.ts +27 -0
  47. package/src/index.ts +8 -0
  48. package/src/resource.ts +28 -0
  49. package/src/types.ts +39 -0
  50. package/tests/coguard.spec.ts +36 -0
  51. package/tests/context.ts +109 -0
  52. package/tests/guard.spec.ts +128 -0
  53. package/tests/handlers.spec.ts +154 -0
  54. package/tests/tsconfig.json +12 -0
  55. 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,8 @@
1
+ export * from './consts.js';
2
+ export * from './resource.js';
3
+ export * from './guard.js';
4
+ export * from './hash.js';
5
+ export * from './coguard.js';
6
+ export * from './append.js';
7
+ export * from './handlers/index.js';
8
+ //# sourceMappingURL=index.js.map
@@ -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"}
@@ -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,2 @@
1
+ export {};
2
+ //# sourceMappingURL=types.js.map
@@ -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
@@ -0,0 +1,2 @@
1
+ /** Service alias of the module, for error locations. */
2
+ export const SERVER_AUTH_TOKEN = 'server-auth-token'
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
@@ -0,0 +1,8 @@
1
+ export * from './consts.js'
2
+ export * from './resource.js'
3
+ export * from './guard.js'
4
+ export * from './hash.js'
5
+ export * from './coguard.js'
6
+ export * from './append.js'
7
+ export * from './handlers/index.js'
8
+ export type * from './types.js'
@@ -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
+ })