@owlmeans/server-auth-token 0.1.18-rc.8 → 0.1.18

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 (79) hide show
  1. package/README.md +177 -1
  2. package/agent-meta/manifest.json +3 -3
  3. package/agent-meta/skills/server-auth-token/SKILL.md +33 -10
  4. package/build/guard.d.ts +1 -3
  5. package/build/guard.d.ts.map +1 -1
  6. package/build/guard.js +37 -14
  7. package/build/guard.js.map +1 -1
  8. package/build/handlers/create.d.ts +5 -0
  9. package/build/handlers/create.d.ts.map +1 -0
  10. package/build/handlers/create.js +18 -0
  11. package/build/handlers/create.js.map +1 -0
  12. package/build/handlers/index.d.ts +11 -11
  13. package/build/handlers/index.d.ts.map +1 -1
  14. package/build/handlers/index.js +8 -93
  15. package/build/handlers/index.js.map +1 -1
  16. package/build/handlers/issue.d.ts +6 -0
  17. package/build/handlers/issue.d.ts.map +1 -0
  18. package/build/handlers/issue.js +42 -0
  19. package/build/handlers/issue.js.map +1 -0
  20. package/build/handlers/list.d.ts +3 -0
  21. package/build/handlers/list.d.ts.map +1 -0
  22. package/build/handlers/list.js +18 -0
  23. package/build/handlers/list.js.map +1 -0
  24. package/build/handlers/refuse.d.ts +19 -0
  25. package/build/handlers/refuse.d.ts.map +1 -0
  26. package/build/handlers/refuse.js +20 -0
  27. package/build/handlers/refuse.js.map +1 -0
  28. package/build/handlers/revoke.d.ts +17 -0
  29. package/build/handlers/revoke.d.ts.map +1 -0
  30. package/build/handlers/revoke.js +35 -0
  31. package/build/handlers/revoke.js.map +1 -0
  32. package/build/handlers/types.d.ts +37 -0
  33. package/build/handlers/types.d.ts.map +1 -0
  34. package/build/handlers/types.js +2 -0
  35. package/build/handlers/types.js.map +1 -0
  36. package/build/handlers/utils/types.d.ts +10 -0
  37. package/build/handlers/utils/types.d.ts.map +1 -0
  38. package/build/handlers/utils/types.js +2 -0
  39. package/build/handlers/utils/types.js.map +1 -0
  40. package/build/handlers/utils.d.ts +4 -0
  41. package/build/handlers/utils.d.ts.map +1 -0
  42. package/build/handlers/utils.js +11 -0
  43. package/build/handlers/utils.js.map +1 -0
  44. package/build/hash/types.d.ts +16 -0
  45. package/build/hash/types.d.ts.map +1 -0
  46. package/build/hash/types.js +2 -0
  47. package/build/hash/types.js.map +1 -0
  48. package/build/hash.d.ts +4 -15
  49. package/build/hash.d.ts.map +1 -1
  50. package/build/hash.js +13 -16
  51. package/build/hash.js.map +1 -1
  52. package/build/index.d.ts +3 -0
  53. package/build/index.d.ts.map +1 -1
  54. package/build/index.js +1 -0
  55. package/build/index.js.map +1 -1
  56. package/build/prefix.d.ts +4 -0
  57. package/build/prefix.d.ts.map +1 -0
  58. package/build/prefix.js +4 -0
  59. package/build/prefix.js.map +1 -0
  60. package/build/types.d.ts +20 -0
  61. package/build/types.d.ts.map +1 -1
  62. package/package.json +17 -16
  63. package/src/guard.ts +39 -16
  64. package/src/handlers/create.ts +21 -0
  65. package/src/handlers/index.ts +17 -115
  66. package/src/handlers/issue.ts +50 -0
  67. package/src/handlers/list.ts +21 -0
  68. package/src/handlers/refuse.ts +20 -0
  69. package/src/handlers/revoke.ts +42 -0
  70. package/src/handlers/types.ts +40 -0
  71. package/src/handlers/utils/types.ts +10 -0
  72. package/src/handlers/utils.ts +18 -0
  73. package/src/hash/types.ts +16 -0
  74. package/src/hash.ts +17 -21
  75. package/src/index.ts +3 -0
  76. package/src/prefix.ts +6 -0
  77. package/src/types.ts +21 -0
  78. package/tests/guard.spec.ts +54 -10
  79. package/tests/handlers.spec.ts +56 -8
package/src/guard.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import type { Auth } from '@owlmeans/auth'
2
2
  import { ALL_SCOPES, AuthroizationType } from '@owlmeans/auth'
3
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
4
+ AUTH_TOKEN_SCHEME, AUTH_TOKEN_RESOURCE, AUTH_TOKEN_TOUCH_INTERVAL,
5
+ BEARER_SCHEME, GUARD_AUTH_TOKEN, tokenFormatHelper
6
6
  } from '@owlmeans/auth-token'
7
7
  import { AUTH_HEADER } from '@owlmeans/auth'
8
8
  import { ENTITY_RESOLVER } from '@owlmeans/auth-common'
@@ -11,12 +11,12 @@ import { createService } from '@owlmeans/context'
11
11
  import type { AbstractRequest, AbstractResponse, GuardService } from '@owlmeans/entrypoint'
12
12
  import { AUTH_IDENTITY_PROFILE } from '@owlmeans/server-auth-identity'
13
13
  import type { IdentityProfile, IdentityProfileResource } from '@owlmeans/server-auth-identity'
14
- import { hashAccessToken } from './hash.js'
14
+ import { tokenHashHelper } from './hash.js'
15
+ import { prefixOf } from './prefix.js'
15
16
  import type { AccessTokenResource, AuthTokenConfig, AuthTokenContext, AuthTokenGuardOptions } from './types.js'
17
+ import { logger, logThrottle } from '@owlmeans/log'
16
18
 
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
19
+ const log = logger('server-auth-token')
20
20
 
21
21
  /**
22
22
  * The guard that verifies a long-lived access token.
@@ -39,14 +39,27 @@ export const prefixOf = (context: AuthTokenContext, opts?: AuthTokenGuardOptions
39
39
  export const makeAuthTokenGuard = (
40
40
  alias: string = GUARD_AUTH_TOKEN, opts: AuthTokenGuardOptions = {}
41
41
  ): GuardService => {
42
+ /**
43
+ * A presented access token the guard did not accept. Never the token, not even its display form:
44
+ * its record id only. Once a minute per token and reason — a client retrying a revoked token calls every second.
45
+ */
46
+ const refuse = (reason: string, record?: { id?: string, entityId?: string }): false => {
47
+ if (logThrottle(`auth-token:${reason}:${record?.id ?? ''}`, 60_000)) {
48
+ log.warn('Access token refused', {
49
+ reason, tokenId: record?.id, entityId: record?.entityId,
50
+ }, { event: 'auth.refused' })
51
+ }
52
+ return false
53
+ }
54
+
42
55
  const touchInterval = opts.touchInterval ?? AUTH_TOKEN_TOUCH_INTERVAL
43
56
 
44
57
  const present = (req: Partial<AbstractRequest>, prefix: string): string | null => {
45
- const parsed = parseAuthorizationHeader(req.headers?.[AUTH_HEADER])
58
+ const parsed = tokenFormatHelper.parseAuthorizationHeader(req.headers?.[AUTH_HEADER])
46
59
  if (parsed == null) return null
47
60
  if (parsed.scheme !== AUTH_TOKEN_SCHEME && parsed.scheme !== BEARER_SCHEME) return null
48
61
 
49
- return isAccessToken(parsed.value, prefix) ? parsed.value : null
62
+ return tokenFormatHelper.isAccessToken(parsed.value, prefix) ? parsed.value : null
50
63
  }
51
64
 
52
65
  const service: GuardService = createService<GuardService>(alias, {
@@ -71,10 +84,20 @@ export const makeAuthTokenGuard = (
71
84
  if (presented == null) return false as T
72
85
 
73
86
  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
87
+ const record = await tokens.load({ hash: tokenHashHelper.hashAccessToken(presented) })
88
+ if (record == null) return refuse('unknown') as T
89
+ if (record.revokedAt != null) return refuse('revoked', record) as T
90
+ if (record.expiresAt != null && new Date(record.expiresAt) < new Date()) return refuse('expired', record) as T
91
+ // A token minted through an OAuth grant carries the resource(s) it was issued FOR. A token
92
+ // with no audience is the ordinary hand-minted kind and is admitted everywhere its scopes
93
+ // reach, exactly as before this check existed — the guard has to OPT IN with `resources` to
94
+ // narrow anything, so a deployment that never touches this option sees no change at all.
95
+ const resources = typeof opts.resources === 'function' ? opts.resources(context) : opts.resources
96
+ if (record.audience != null && record.audience.length > 0
97
+ && resources != null && resources.length > 0
98
+ && !record.audience.some(resource => resources.includes(resource))) {
99
+ return refuse('audience', record) as T
100
+ }
78
101
 
79
102
  // A token is only ever as good as the profile behind it. Reading the profile per request is
80
103
  // what makes revoking a person's access revoke every token they ever minted, without any
@@ -86,10 +109,10 @@ export const makeAuthTokenGuard = (
86
109
  try {
87
110
  profile = await profiles.load({ entityId: record.entityId, profileId: record.profileId })
88
111
  } catch {
89
- return false as T
112
+ return refuse('profile-unreadable', record) as T
90
113
  }
91
- if (profile == null) return false as T
92
- if (profile.expiresAt != null && new Date(profile.expiresAt) < new Date()) return false as T
114
+ if (profile == null) return refuse('profile', record) as T
115
+ if (profile.expiresAt != null && new Date(profile.expiresAt) < new Date()) return refuse('profile-expired', record) as T
93
116
 
94
117
  const profileScopes = profile.scopes ?? []
95
118
  const scopes = profileScopes.includes(ALL_SCOPES)
@@ -129,7 +152,7 @@ export const makeAuthTokenGuard = (
129
152
  // Fire and forget: a usage timestamp is never a reason to fail a request, and awaiting it
130
153
  // would put a database write on the critical path of every authenticated call.
131
154
  void tokens.save({ ...record, lastUsedAt: new Date() })
132
- .catch((e: unknown) => console.error('auth-token: lastUsedAt', e))
155
+ .catch((e: unknown) => { log.warn('Access token lastUsedAt not saved', { tokenId: record.id, error: e }) })
133
156
  }
134
157
 
135
158
  return true as T
@@ -0,0 +1,21 @@
1
+ import { AuthForbidden, AuthRole } from '@owlmeans/auth'
2
+ import type { AuthTokenEntrypoints } from '@owlmeans/auth-token'
3
+ import { makeEntityScope } from '@owlmeans/auth-common'
4
+ import { handlers } from '@owlmeans/server-api'
5
+ import type { AuthTokenContext } from '../types.js'
6
+ import { accessTokenIssuerOf } from './issue.js'
7
+ import { refuseTokenAuth } from './refuse.js'
8
+
9
+ export const createAccessToken = (protocol: AuthTokenEntrypoints['create']) => handlers<AuthTokenContext>().body(protocol, async (payload, context, req) => {
10
+ const ctx = context as AuthTokenContext
11
+ refuseTokenAuth(req, 'token-mint')
12
+
13
+ const entityId = makeEntityScope(req).requireEntityKey()
14
+ const profileId = req.auth?.profileId
15
+ const userId = req.auth?.userId
16
+ if (profileId == null || userId == null) throw new AuthForbidden('profile')
17
+
18
+ return accessTokenIssuerOf(ctx).issueAccessToken({
19
+ entityId, profileId, userId, role: req.auth?.role ?? AuthRole.User, scopes: req.auth?.scopes ?? [],
20
+ }, payload)
21
+ })
@@ -1,115 +1,17 @@
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, AccessTokenRecord, AccessTokenView, IssuedAccessToken
7
- } from '@owlmeans/auth-token'
8
- import { requireEntityKey } from '@owlmeans/auth-common'
9
- import { handlers } from '@owlmeans/server-api'
10
- import { mintAccessToken } from '../hash.js'
11
- import { prefixOf } from '../guard.js'
12
- import type { AccessTokenResource, AuthTokenContext } from '../types.js'
13
- import type { AuthTokenEntrypoints } from '@owlmeans/auth-token'
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 = (protocol: AuthTokenEntrypoints['list']) => handlers<AuthTokenContext>().request(protocol, 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 = (protocol: AuthTokenEntrypoints['create']) => handlers<AuthTokenContext>().body(protocol, 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 = (protocol: AuthTokenEntrypoints['revoke']) => handlers<AuthTokenContext>().params(protocol, 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
- })
1
+ import type { IssuedAccessToken } from '@owlmeans/auth-token'
2
+ import type { AuthTokenContext } from '../types.js'
3
+ import { accessTokenIssuerOf } from './issue.js'
4
+ import type { IssueAccessTokenRequest, IssueAccessTokenSubject } from './types.js'
5
+
6
+ export * from './refuse.js'
7
+ export * from './issue.js'
8
+ export * from './list.js'
9
+ export * from './create.js'
10
+ export * from './revoke.js'
11
+
12
+ export type { IssueAccessTokenRequest, IssueAccessTokenSubject, RevokeAccessTokenOptions } from './types.js'
13
+
14
+ /** @deprecated compat:factory-refactor — use `accessTokenIssuerOf(ctx).issueAccessToken(…)` */
15
+ export const issueAccessToken = async (
16
+ ctx: AuthTokenContext, subject: IssueAccessTokenSubject, payload: IssueAccessTokenRequest
17
+ ): Promise<IssuedAccessToken> => await accessTokenIssuerOf(ctx).issueAccessToken(subject, payload)
@@ -0,0 +1,50 @@
1
+ import { AuthForbidden, ALL_SCOPES } from '@owlmeans/auth'
2
+ import { AUTH_TOKEN_MAX_TTL, type AccessTokenRecord, type IssuedAccessToken } from '@owlmeans/auth-token'
3
+ import { memoHelper } from '@owlmeans/context'
4
+ import { tokenHashHelper } from '../hash.js'
5
+ import { prefixOf } from '../prefix.js'
6
+ import type { AuthTokenContext } from '../types.js'
7
+ import type { AccessTokenIssuer, IssueAccessTokenRequest, IssueAccessTokenSubject } from './types.js'
8
+ import { tokenRecordUtils } from './utils.js'
9
+
10
+ export const makeAccessTokenIssuer = (ctx: AuthTokenContext): AccessTokenIssuer => {
11
+ const issueAccessToken = async (
12
+ subject: IssueAccessTokenSubject, payload: IssueAccessTokenRequest
13
+ ): Promise<IssuedAccessToken> => {
14
+ const own = subject.scopes
15
+ const scopes = payload.scopes ?? own
16
+ // A token is a delegation, so it can only ever narrow. Asking for a scope the caller does not
17
+ // hold is refused rather than silently dropped: a token that quietly grants less than it was
18
+ // asked for fails later, somewhere else, with an error about the wrong thing.
19
+ if (!own.includes(ALL_SCOPES) && scopes.some(scope => !own.includes(scope))) {
20
+ throw new AuthForbidden('scope')
21
+ }
22
+
23
+ // The SAME resolver the guard uses, so a minted token is one this deployment's guard claims.
24
+ const minted = tokenHashHelper.mintAccessToken(prefixOf(ctx))
25
+ const now = new Date()
26
+ const record = await tokenRecordUtils.tokens(ctx).create({
27
+ hash: minted.hash,
28
+ display: minted.display,
29
+ name: payload.name,
30
+ userId: subject.userId,
31
+ profileId: subject.profileId,
32
+ entityId: subject.entityId,
33
+ scopes,
34
+ role: subject.role,
35
+ createdAt: now,
36
+ ...(payload.audience != null ? { audience: payload.audience } : {}),
37
+ ...(payload.expiresIn != null
38
+ ? { expiresAt: new Date(now.getTime() + Math.min(payload.expiresIn * 1000, AUTH_TOKEN_MAX_TTL)) }
39
+ : {}),
40
+ } as AccessTokenRecord)
41
+
42
+ // The only moment the plaintext exists outside the caller's own machine.
43
+ return { token: minted.token, record: tokenRecordUtils.view(record) } satisfies IssuedAccessToken
44
+ }
45
+
46
+ return { issueAccessToken }
47
+ }
48
+
49
+ /** The token issuer of a context — one per context. */
50
+ export const accessTokenIssuerOf = memoHelper.oncePer(makeAccessTokenIssuer)
@@ -0,0 +1,21 @@
1
+ import { AuthForbidden } from '@owlmeans/auth'
2
+ import type { AccessTokenList, AuthTokenEntrypoints } from '@owlmeans/auth-token'
3
+ import { makeEntityScope } from '@owlmeans/auth-common'
4
+ import { handlers } from '@owlmeans/server-api'
5
+ import type { AuthTokenContext } from '../types.js'
6
+ import { tokenRecordUtils } from './utils.js'
7
+
8
+ export const listAccessTokens = (protocol: AuthTokenEntrypoints['list']) => handlers<AuthTokenContext>().request(protocol, async (req, context) => {
9
+ const ctx = context as AuthTokenContext
10
+ const entityId = makeEntityScope(req).requireEntityKey()
11
+ const profileId = req.auth?.profileId
12
+ if (profileId == null) throw new AuthForbidden('profile')
13
+
14
+ const result = await tokenRecordUtils.tokens(ctx).list({ entityId, profileId }, { size: 0 })
15
+
16
+ return {
17
+ items: result.items
18
+ .map(tokenRecordUtils.view)
19
+ .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()),
20
+ } satisfies AccessTokenList
21
+ })
@@ -0,0 +1,20 @@
1
+ import { AuthForbidden, AuthroizationType } from '@owlmeans/auth'
2
+
3
+ /**
4
+ * A token may never mint another token.
5
+ *
6
+ * Minting is the one operation that turns a stolen credential into a permanent one: a token that
7
+ * can create tokens survives the revocation of the token that leaked. Creating and revoking
8
+ * therefore require a credential a person produced in a browser — which is also why the deployment
9
+ * names these routes in the guard's deny list, so the refusal is a 401 at the boundary rather than
10
+ * a check every future handler has to remember.
11
+ *
12
+ * Exported because an OAuth authorization server's consent-approval handler
13
+ * (`@owlmeans/server-oauth`) mints tokens too and needs the exact same guard, over a session it
14
+ * verified itself rather than a route this package bound.
15
+ */
16
+ export const refuseTokenAuth = (req: { auth?: { type?: string } }, what: string): void => {
17
+ if (req.auth?.type === AuthroizationType.AuthToken) {
18
+ throw new AuthForbidden(what)
19
+ }
20
+ }
@@ -0,0 +1,42 @@
1
+ import { AuthForbidden } from '@owlmeans/auth'
2
+ import type { AuthTokenEntrypoints } from '@owlmeans/auth-token'
3
+ import { makeEntityScope } from '@owlmeans/auth-common'
4
+ import { handlers } from '@owlmeans/server-api'
5
+ import type { AuthTokenContext } from '../types.js'
6
+ import { refuseTokenAuth } from './refuse.js'
7
+ import type { RevokeAccessTokenOptions } from './types.js'
8
+ import { tokenRecordUtils } from './utils.js'
9
+
10
+ /**
11
+ * Revoke one of the caller's own tokens.
12
+ *
13
+ * By default only a credential a person produced in a browser may revoke. `allowAccessTokens`
14
+ * admits an access token too — a deployment's coding-agent surface that lets the person clean up
15
+ * their own tokens — and changes nothing else: still the caller's OWN profile in their OWN
16
+ * organization, and a foreign token still answers exactly as an unknown id does. Revoking never
17
+ * mints, so it cannot turn a stolen token into a lasting one; minting stays refused regardless.
18
+ */
19
+ export const revokeAccessToken = (
20
+ protocol: AuthTokenEntrypoints['revoke'], opts: RevokeAccessTokenOptions = {},
21
+ ) => handlers<AuthTokenContext>().params(protocol, async (payload, context, req) => {
22
+ const ctx = context as AuthTokenContext
23
+ if (opts.allowAccessTokens !== true) refuseTokenAuth(req, 'token-revoke')
24
+
25
+ const entityId = makeEntityScope(req).requireEntityKey()
26
+ const profileId = req.auth?.profileId
27
+ if (profileId == null) throw new AuthForbidden('profile')
28
+
29
+ const record = await tokenRecordUtils.tokens(ctx).load(payload.id)
30
+ // A token of another profile answers exactly as an unknown id does — an owner learns nothing
31
+ // about tokens that are not theirs, not even that one exists.
32
+ if (record == null || record.entityId !== entityId || record.profileId !== profileId) {
33
+ throw new AuthForbidden('token')
34
+ }
35
+
36
+ // Idempotent: revoking twice is what a retry looks like.
37
+ if (record.revokedAt == null) {
38
+ await tokenRecordUtils.tokens(ctx).save({ ...record, revokedAt: new Date(), updatedAt: new Date() })
39
+ }
40
+
41
+ return { id: payload.id }
42
+ })
@@ -0,0 +1,40 @@
1
+ import { AuthRole } from '@owlmeans/auth'
2
+ import type { CreateAccessToken, IssuedAccessToken } from '@owlmeans/auth-token'
3
+
4
+ /** Whoever is minting the token — read once, by whichever caller resolved the session. */
5
+ export interface IssueAccessTokenSubject {
6
+ entityId: string
7
+ userId: string
8
+ profileId: string
9
+ role: AuthRole
10
+ /** The subject's OWN scopes — minting can only narrow, never widen, this set. */
11
+ scopes: string[]
12
+ }
13
+
14
+ export interface IssueAccessTokenRequest extends CreateAccessToken {
15
+ /**
16
+ * The resource(s) this token is FOR (RFC 8707), when it is minted through an OAuth grant.
17
+ * Absent for a token minted by hand — the ordinary, unrestricted kind.
18
+ */
19
+ audience?: string[]
20
+ }
21
+
22
+ /** Token issuance over one context's store — the one path every minting caller goes through. */
23
+ export interface AccessTokenIssuer {
24
+ /**
25
+ * Mint one token for `subject`, exactly what `createAccessToken` does over an HTTP body — pulled
26
+ * out so a second caller (an OAuth token endpoint) can mint the SAME shape of record over a
27
+ * session it authenticated its own way, without a duplicate copy of the narrowing rule or the
28
+ * TTL clamp silently drifting from this one.
29
+ */
30
+ issueAccessToken: (subject: IssueAccessTokenSubject, payload: IssueAccessTokenRequest) => Promise<IssuedAccessToken>
31
+ }
32
+
33
+ /** How a deployment binds `revokeAccessToken`. */
34
+ export interface RevokeAccessTokenOptions {
35
+ /**
36
+ * Let an access token revoke one of its own person's tokens (itself included). Defaults to
37
+ * `false`: only an interactive session revokes. Ownership is checked the same either way.
38
+ */
39
+ allowAccessTokens?: boolean
40
+ }
@@ -0,0 +1,10 @@
1
+ import type { AccessTokenRecord, AccessTokenView } from '@owlmeans/auth-token'
2
+ import type { AccessTokenResource, AuthTokenContext } from '../../types.js'
3
+
4
+ /** What every token handler shares: the store and the view a caller may see. */
5
+ export interface TokenRecordUtils {
6
+ /** A record without its hash — the hash never leaves the server. */
7
+ view: (record: AccessTokenRecord) => AccessTokenView
8
+ /** The token store of a context. */
9
+ tokens: (ctx: AuthTokenContext) => AccessTokenResource
10
+ }
@@ -0,0 +1,18 @@
1
+ import { AUTH_TOKEN_RESOURCE, type AccessTokenRecord, type AccessTokenView } from '@owlmeans/auth-token'
2
+ import type { AccessTokenResource, AuthTokenContext } from '../types.js'
3
+ import type { TokenRecordUtils } from './utils/types.js'
4
+
5
+ export const createTokenRecordUtils = (): TokenRecordUtils => {
6
+ const view = (record: AccessTokenRecord): AccessTokenView => {
7
+ const { hash, ...rest } = record
8
+
9
+ return rest
10
+ }
11
+
12
+ const tokens = (ctx: AuthTokenContext): AccessTokenResource =>
13
+ ctx.resource<AccessTokenResource>(AUTH_TOKEN_RESOURCE)
14
+
15
+ return { view, tokens }
16
+ }
17
+
18
+ export const tokenRecordUtils = createTokenRecordUtils()
@@ -0,0 +1,16 @@
1
+ import type { MintedToken } from '../types.js'
2
+
3
+ /** Access tokens minted and stored: the plaintext once, the hash forever. */
4
+ export interface TokenHashHelper {
5
+ /**
6
+ * The stored form of a token.
7
+ *
8
+ * SHA-256 rather than a password hash on purpose: the input is 192 bits of machine-generated
9
+ * randomness, so there is no dictionary to slow down, and the hash is computed on every single
10
+ * API request — a deliberately slow function here would be a deliberate rate limit on the
11
+ * deployment's whole authenticated surface.
12
+ */
13
+ hashAccessToken: (token: string) => string
14
+ /** Mint one token: the plaintext (returned once), its hash (stored) and its display form. */
15
+ mintAccessToken: (prefix: string) => MintedToken
16
+ }
package/src/hash.ts CHANGED
@@ -1,27 +1,23 @@
1
- import { AUTH_TOKEN_SECRET_BYTES, displayOf } from '@owlmeans/auth-token'
1
+ import { AUTH_TOKEN_SECRET_BYTES, tokenFormatHelper } from '@owlmeans/auth-token'
2
2
  import { base58 } from '@scure/base'
3
- import { sha256 } from '@noble/hashes/sha256'
4
- import { bytesToHex, randomBytes } from '@noble/hashes/utils'
3
+ import { sha256 } from '@noble/hashes/sha2.js'
4
+ import { bytesToHex, randomBytes } from '@noble/hashes/utils.js'
5
+ import type { MintedToken } from './types.js'
6
+ import type { TokenHashHelper } from './hash/types.js'
5
7
 
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)))
8
+ export const createTokenHashHelper = (): TokenHashHelper => {
9
+ const hashAccessToken = (token: string): string => bytesToHex(sha256(new TextEncoder().encode(token)))
15
10
 
16
- export interface MintedToken {
17
- token: string
18
- hash: string
19
- display: string
20
- }
11
+ const mintAccessToken = (prefix: string): MintedToken => {
12
+ const token = `${prefix}${base58.encode(randomBytes(AUTH_TOKEN_SECRET_BYTES))}`
21
13
 
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))}`
14
+ return { token, hash: hashAccessToken(token), display: tokenFormatHelper.displayOf(token, prefix) }
15
+ }
25
16
 
26
- return { token, hash: hashAccessToken(token), display: displayOf(token, prefix) }
17
+ return { hashAccessToken, mintAccessToken }
27
18
  }
19
+
20
+ export const tokenHashHelper = createTokenHashHelper()
21
+
22
+ /** @deprecated compat:factory-refactor — use `tokenHashHelper.hashAccessToken(…)` */
23
+ export const hashAccessToken = (token: string): string => tokenHashHelper.hashAccessToken(token)
package/src/index.ts CHANGED
@@ -1,8 +1,11 @@
1
1
  export * from './consts.js'
2
2
  export * from './resource.js'
3
+ export * from './prefix.js'
3
4
  export * from './guard.js'
4
5
  export * from './hash.js'
5
6
  export * from './coguard.js'
6
7
  export * from './append.js'
7
8
  export * from './handlers/index.js'
9
+ export type * from './handlers/types.js'
8
10
  export type * from './types.js'
11
+ export type * from './hash/types.js'
package/src/prefix.ts ADDED
@@ -0,0 +1,6 @@
1
+ import { AUTH_TOKEN_DEFAULT_PREFIX } from '@owlmeans/auth-token'
2
+ import type { AuthTokenConfig, AuthTokenContext, AuthTokenGuardOptions } from './types.js'
3
+
4
+ /** Where the deployment's prefix comes from: the guard's own options, then config, then default. */
5
+ export const prefixOf = (context: AuthTokenContext, opts?: AuthTokenGuardOptions): string =>
6
+ opts?.prefix ?? (context.cfg as AuthTokenConfig).authToken?.prefix ?? AUTH_TOKEN_DEFAULT_PREFIX
package/src/types.ts CHANGED
@@ -28,6 +28,21 @@ export interface AuthTokenGuardOptions {
28
28
  resourceAlias?: string
29
29
  /** Resource alias holding the identity profiles a token is bound to. */
30
30
  profileAlias?: string
31
+ /**
32
+ * The resource(s) this guard speaks for, opting it into audience admission.
33
+ *
34
+ * Left unset (the default), the guard admits every token whatever its `audience` — full
35
+ * backward compatibility for a deployment that never mints an audience-restricted token. Set
36
+ * it to the canonical resource URI(s) this route surface serves, and a token issued FOR a
37
+ * different resource (an OAuth-issued, MCP-only token, say) is refused here rather than
38
+ * quietly working everywhere the profile's scopes would otherwise reach.
39
+ *
40
+ * A function is resolved with the live context on every request rather than once at
41
+ * registration time — `appendAuthTokenGuard` runs from `makeContext`, before a hostname a
42
+ * Kubernetes secret mount populates is necessarily readable yet, and this guard's `handle`
43
+ * already runs per-request regardless.
44
+ */
45
+ resources?: string[] | ((context: AuthTokenContext) => string[])
31
46
  }
32
47
 
33
48
  export interface AuthTokenConfig extends ServerConfig {
@@ -37,3 +52,9 @@ export interface AuthTokenConfig extends ServerConfig {
37
52
  }
38
53
 
39
54
  export type AuthTokenContext<C extends AuthTokenConfig = AuthTokenConfig> = ServerContext<C>
55
+
56
+ export interface MintedToken {
57
+ token: string
58
+ hash: string
59
+ display: string
60
+ }