@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,109 @@
1
+ import { AppType, makeBasicContext } from '@owlmeans/context'
2
+ import type { BasicConfig, BasicContext } from '@owlmeans/context'
3
+ import { createStaticResource } from '@owlmeans/static-resource'
4
+ import { AUTH_IDENTITY_PROFILE } from '@owlmeans/server-auth-identity'
5
+ import type { IdentityProfile } from '@owlmeans/server-auth-identity'
6
+ import { AUTH_TOKEN_RESOURCE, GUARD_AUTH_TOKEN } from '@owlmeans/auth-token'
7
+ import type { AccessTokenRecord } from '@owlmeans/auth-token'
8
+ import { AuthRole } from '@owlmeans/auth'
9
+ import { appendAuthTokenGuard } from '../src/append.js'
10
+ import type { AuthTokenGuardOptions } from '../src/types.js'
11
+
12
+ export const TEST_PREFIX = 'tst_'
13
+ export const TEST_ENTITY = 'entity-1'
14
+ export const TEST_PROFILE = 'profile-1'
15
+ export const TEST_USER = 'user-1'
16
+
17
+ /**
18
+ * A context with the two resources the guard reads and nothing else.
19
+ *
20
+ * Static resources rather than Mongo: the guard's contract is "which record makes a request
21
+ * authentic", and a database adds nothing to that question. The identity profile is a REAL record
22
+ * shape from `@owlmeans/server-auth-identity` — the guard reads a token's authority off it, so a
23
+ * stand-in of the wrong shape would let the intersection rule pass untested.
24
+ */
25
+ // A static resource's store is module-level and keyed by its storage key, so two contexts sharing
26
+ // one key share one store — and the second test's seed collides with the first's. A key per
27
+ // context is what makes each test start empty.
28
+ let store = 0
29
+ let seq = 0
30
+
31
+ export const makeTestContext = async (opts: AuthTokenGuardOptions = {}) => {
32
+ const key = `auth-token-tests-${++store}`
33
+ const cfg: BasicConfig = {
34
+ ready: false, service: 'server-auth-token-tests', type: AppType.Backend, services: {},
35
+ }
36
+ const context = makeBasicContext(cfg) as BasicContext<BasicConfig>
37
+
38
+ // A record store that mints an id when the caller does not supply one — which is what every
39
+ // real backend does and what the static resource, alone among them, refuses to do. The handlers
40
+ // let their store assign ids, so without this the test would be asserting a property of the
41
+ // test double rather than of the code.
42
+ const identified = <T extends { id?: string }>(resource: any): any => {
43
+ const create = resource.create.bind(resource)
44
+ resource.create = async (record: T, opts?: unknown) =>
45
+ await create({ ...record, id: record.id ?? `rec-${++seq}` }, opts)
46
+
47
+ return resource
48
+ }
49
+
50
+ context.registerResource(identified(createStaticResource<AccessTokenRecord>(AUTH_TOKEN_RESOURCE, `${key}-tokens`)))
51
+ context.registerResource(identified(createStaticResource<IdentityProfile>(AUTH_IDENTITY_PROFILE, `${key}-profiles`)))
52
+ // Exactly how a deployment wires it: one call that seats the guard AND records the prefix, so
53
+ // the minting handler and the guard cannot disagree about what a token of this deployment is.
54
+ appendAuthTokenGuard(context, GUARD_AUTH_TOKEN, { prefix: TEST_PREFIX, ...opts })
55
+
56
+ context.configure()
57
+ await context.init()
58
+
59
+ return context
60
+ }
61
+
62
+ export const seedProfile = async (
63
+ context: BasicContext<BasicConfig>, patch: Partial<IdentityProfile> = {}
64
+ ): Promise<IdentityProfile> => {
65
+ const profiles = context.resource<any>(AUTH_IDENTITY_PROFILE)
66
+
67
+ return await profiles.create({
68
+ id: patch.id ?? `profile-record-${TEST_PROFILE}`,
69
+ profileId: TEST_PROFILE,
70
+ entityId: TEST_ENTITY,
71
+ userId: TEST_USER,
72
+ role: AuthRole.User,
73
+ scopes: ['*'],
74
+ ...patch,
75
+ })
76
+ }
77
+
78
+ export const seedToken = async (
79
+ context: BasicContext<BasicConfig>, hash: string, patch: Partial<AccessTokenRecord> = {}
80
+ ): Promise<AccessTokenRecord> => {
81
+ const tokens = context.resource<any>(AUTH_TOKEN_RESOURCE)
82
+
83
+ return await tokens.create({
84
+ id: patch.id ?? hash.slice(0, 12),
85
+ hash,
86
+ display: `${TEST_PREFIX}display`,
87
+ name: 'test token',
88
+ userId: TEST_USER,
89
+ profileId: TEST_PROFILE,
90
+ entityId: TEST_ENTITY,
91
+ scopes: ['*'],
92
+ role: AuthRole.User,
93
+ createdAt: new Date(),
94
+ ...patch,
95
+ })
96
+ }
97
+
98
+ /** The two things a guard is handed: a request carrying a header, and a response to resolve into. */
99
+ export const request = (authorization?: string, alias?: string): any => ({
100
+ alias,
101
+ headers: authorization != null ? { authorization } : {},
102
+ params: {}, query: {}, body: {},
103
+ })
104
+
105
+ export const response = (): any => {
106
+ const res: any = { resolve: (value: unknown) => { res.value = value } }
107
+
108
+ return res
109
+ }
@@ -0,0 +1,128 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { AuthroizationType } from '@owlmeans/auth'
3
+ import { GUARD_AUTH_TOKEN } from '@owlmeans/auth-token'
4
+ import type { GuardService } from '@owlmeans/entrypoint'
5
+ import { hashAccessToken } from '../src/hash.js'
6
+ import {
7
+ makeTestContext, request, response, seedProfile, seedToken, TEST_ENTITY, TEST_PREFIX, TEST_PROFILE
8
+ } from './context.js'
9
+
10
+ const TOKEN = `${TEST_PREFIX}averylongrandomsecretvalue`
11
+
12
+ const armed = async (opts = {}) => {
13
+ const context = await makeTestContext(opts)
14
+ await seedProfile(context)
15
+ await seedToken(context, hashAccessToken(TOKEN))
16
+
17
+ return { context, guard: context.service<GuardService>(GUARD_AUTH_TOKEN) }
18
+ }
19
+
20
+ describe('@owlmeans/server-auth-token — what the guard claims', () => {
21
+ test('claims a prefixed value under either scheme', async () => {
22
+ const { guard } = await armed()
23
+
24
+ expect(await guard.match(request(`AUTH-TOKEN ${TOKEN}`), response())).toBe(true)
25
+ expect(await guard.match(request(`Bearer ${TOKEN}`), response())).toBe(true)
26
+ })
27
+
28
+ test('leaves every other credential alone', async () => {
29
+ const { guard } = await armed()
30
+
31
+ // The one that matters: an OwlMeans session bearer must reach its own guard untouched.
32
+ expect(await guard.match(request('ED25519-BASIC-TOKEN abcdef'), response())).toBe(false)
33
+ // A Bearer without this deployment's prefix belongs to somebody else — an OIDC access token.
34
+ expect(await guard.match(request('Bearer some.jwt.value'), response())).toBe(false)
35
+ expect(await guard.match(request(), response())).toBe(false)
36
+ })
37
+
38
+ test('refuses a denied route outright, so the boundary answers 401', async () => {
39
+ const { guard } = await armed({ denyAliases: ['payment:checkout'] })
40
+
41
+ expect(await guard.match(request(`Bearer ${TOKEN}`, 'payment:checkout'), response())).toBe(false)
42
+ expect(await guard.match(request(`Bearer ${TOKEN}`, 'project:list'), response())).toBe(true)
43
+ })
44
+ })
45
+
46
+ describe('@owlmeans/server-auth-token — what the guard resolves', () => {
47
+ test('builds the same Auth payload a session would, and never echoes the secret', async () => {
48
+ const { guard } = await armed()
49
+ const res = response()
50
+
51
+ expect(await guard.handle(request(`Bearer ${TOKEN}`), res)).toBe(true)
52
+ expect(res.value.type).toBe(AuthroizationType.AuthToken)
53
+ expect(res.value.profileId).toBe(TEST_PROFILE)
54
+ expect(res.value.entitySlug).toBe(TEST_ENTITY)
55
+ expect(res.value.isUser).toBe(true)
56
+ expect(res.value.token).not.toBe(TOKEN)
57
+ expect(res.value.token).toBe(`${TEST_PREFIX}display`)
58
+ })
59
+
60
+ test('a token can never outrank its profile', async () => {
61
+ const context = await makeTestContext()
62
+ await seedProfile(context, { scopes: ['project:read'] })
63
+ await seedToken(context, hashAccessToken(TOKEN), { scopes: ['*', 'project:write'] })
64
+ const guard = context.service<GuardService>(GUARD_AUTH_TOKEN)
65
+ const res = response()
66
+
67
+ expect(await guard.handle(request(`Bearer ${TOKEN}`), res)).toBe(true)
68
+ expect(res.value.scopes).toEqual(['project:read'])
69
+ })
70
+
71
+ test('refuses a revoked token', async () => {
72
+ const context = await makeTestContext()
73
+ await seedProfile(context)
74
+ await seedToken(context, hashAccessToken(TOKEN), { revokedAt: new Date() })
75
+ const guard = context.service<GuardService>(GUARD_AUTH_TOKEN)
76
+
77
+ expect(await guard.handle(request(`Bearer ${TOKEN}`), response())).toBe(false)
78
+ })
79
+
80
+ test('refuses an expired token', async () => {
81
+ const context = await makeTestContext()
82
+ await seedProfile(context)
83
+ await seedToken(context, hashAccessToken(TOKEN), { expiresAt: new Date(Date.now() - 1000) })
84
+ const guard = context.service<GuardService>(GUARD_AUTH_TOKEN)
85
+
86
+ expect(await guard.handle(request(`Bearer ${TOKEN}`), response())).toBe(false)
87
+ })
88
+
89
+ test('refuses an unknown secret', async () => {
90
+ const { guard } = await armed()
91
+
92
+ expect(await guard.handle(request(`Bearer ${TEST_PREFIX}notthisone`), response())).toBe(false)
93
+ })
94
+
95
+ test('refuses when the profile behind it is gone — revoking a person revokes their tokens', async () => {
96
+ const context = await makeTestContext()
97
+ await seedToken(context, hashAccessToken(TOKEN))
98
+ const guard = context.service<GuardService>(GUARD_AUTH_TOKEN)
99
+
100
+ expect(await guard.handle(request(`Bearer ${TOKEN}`), response())).toBe(false)
101
+ })
102
+
103
+ test('refuses when the profile has expired', async () => {
104
+ const context = await makeTestContext()
105
+ await seedProfile(context, { expiresAt: new Date(Date.now() - 1000) })
106
+ await seedToken(context, hashAccessToken(TOKEN))
107
+ const guard = context.service<GuardService>(GUARD_AUTH_TOKEN)
108
+
109
+ expect(await guard.handle(request(`Bearer ${TOKEN}`), response())).toBe(false)
110
+ })
111
+
112
+ test('produces no credential of its own — it verifies, it does not authenticate', async () => {
113
+ const { guard } = await armed()
114
+
115
+ expect(await guard.authenticated()).toBeNull()
116
+ })
117
+ })
118
+
119
+ describe('@owlmeans/server-auth-token — the hash at rest', () => {
120
+ test('the stored value is not the token', () => {
121
+ const hash = hashAccessToken(TOKEN)
122
+
123
+ expect(hash).not.toBe(TOKEN)
124
+ expect(hash).toMatch(/^[0-9a-f]{64}$/)
125
+ expect(hashAccessToken(TOKEN)).toBe(hash)
126
+ expect(hashAccessToken(`${TOKEN}x`)).not.toBe(hash)
127
+ })
128
+ })
@@ -0,0 +1,154 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { AuthroizationType, AuthRole } from '@owlmeans/auth'
3
+ import { AUTH_TOKEN_RESOURCE } from '@owlmeans/auth-token'
4
+ import type { AccessTokenRecord, IssuedAccessToken } from '@owlmeans/auth-token'
5
+ import { createAccessToken, listAccessTokens, revokeAccessToken } from '../src/handlers/index.js'
6
+ import { hashAccessToken } from '../src/hash.js'
7
+ import { makeTestContext, seedProfile, seedToken, TEST_ENTITY, TEST_PREFIX, TEST_PROFILE, TEST_USER } from './context.js'
8
+
9
+ /**
10
+ * A handler is a `RefedEntrypointHandler`: it is given the entrypoint reference it was elevated
11
+ * onto and answers with `(req, res)`. Calling it directly is what lets these tests be about the
12
+ * RULES — who may mint, what a list shows, whose token may be revoked — rather than about routing.
13
+ */
14
+ const invoke = async (handler: any, context: any, req: any): Promise<any> => {
15
+ const res: any = { resolve: (value: unknown) => { res.value = value }, reject: (e: Error) => { res.error = e } }
16
+ await handler({ ref: { ctx: context } })(req, res)
17
+ if (res.error != null) throw res.error
18
+
19
+ return res.value
20
+ }
21
+
22
+ const session = (patch: Record<string, unknown> = {}): any => ({
23
+ headers: {}, params: {}, query: {}, body: {},
24
+ entity: { id: TEST_ENTITY, slug: TEST_ENTITY },
25
+ auth: {
26
+ type: AuthroizationType.Ed25519BasicToken,
27
+ profileId: TEST_PROFILE,
28
+ userId: TEST_USER,
29
+ entitySlug: TEST_ENTITY,
30
+ role: AuthRole.User,
31
+ scopes: ['*'],
32
+ ...patch,
33
+ },
34
+ })
35
+
36
+ describe('@owlmeans/server-auth-token — minting', () => {
37
+ test('returns the plaintext once and stores only its hash', async () => {
38
+ const context = await makeTestContext()
39
+ await seedProfile(context)
40
+
41
+ const issued: IssuedAccessToken = await invoke(
42
+ createAccessToken, context, { ...session(), body: { name: 'my laptop' } }
43
+ )
44
+
45
+ expect(issued.token).toStartWith(TEST_PREFIX)
46
+ expect(issued.record.name).toBe('my laptop')
47
+ expect(issued.record.display).toStartWith(TEST_PREFIX)
48
+ expect(issued.record.display.length).toBeLessThan(issued.token.length)
49
+ // The view a caller sees carries no hash at all.
50
+ expect((issued.record as unknown as AccessTokenRecord).hash).toBeUndefined()
51
+
52
+ const stored = await context.resource<any>(AUTH_TOKEN_RESOURCE).load({ hash: hashAccessToken(issued.token) })
53
+ expect(stored).not.toBeNull()
54
+ expect(stored.hash).not.toBe(issued.token)
55
+ })
56
+
57
+ test('two tokens are never the same token', async () => {
58
+ const context = await makeTestContext()
59
+ await seedProfile(context)
60
+
61
+ const first: IssuedAccessToken = await invoke(createAccessToken, context, { ...session(), body: { name: 'a' } })
62
+ const second: IssuedAccessToken = await invoke(createAccessToken, context, { ...session(), body: { name: 'b' } })
63
+
64
+ expect(first.token).not.toBe(second.token)
65
+ })
66
+
67
+ test('an expiry is stored as a moment, not as a duration', async () => {
68
+ const context = await makeTestContext()
69
+ await seedProfile(context)
70
+
71
+ const issued: IssuedAccessToken = await invoke(
72
+ createAccessToken, context, { ...session(), body: { name: 'temporary', expiresIn: 3600 } }
73
+ )
74
+
75
+ expect(issued.record.expiresAt).toBeDefined()
76
+ expect(new Date(issued.record.expiresAt!).getTime()).toBeGreaterThan(Date.now())
77
+ })
78
+
79
+ test('a token may never mint another token', async () => {
80
+ const context = await makeTestContext()
81
+ await seedProfile(context)
82
+
83
+ const req = { ...session({ type: AuthroizationType.AuthToken }), body: { name: 'escalation' } }
84
+ await expect(invoke(createAccessToken, context, req)).rejects.toThrow()
85
+ })
86
+
87
+ test('a token may never widen beyond the caller it was minted by', async () => {
88
+ const context = await makeTestContext()
89
+ await seedProfile(context)
90
+
91
+ const req = {
92
+ ...session({ scopes: ['project:read'] }),
93
+ body: { name: 'greedy', scopes: ['project:read', 'project:write'] },
94
+ }
95
+ await expect(invoke(createAccessToken, context, req)).rejects.toThrow()
96
+ })
97
+ })
98
+
99
+ describe('@owlmeans/server-auth-token — listing and revoking', () => {
100
+ test('a list shows the caller\'s own tokens and never a hash', async () => {
101
+ const context = await makeTestContext()
102
+ await seedProfile(context)
103
+ await seedToken(context, hashAccessToken('tst_one'), { id: 'one', name: 'one' })
104
+ await seedToken(context, hashAccessToken('tst_two'), { id: 'two', name: 'two' })
105
+ // Somebody else's token, in the same store.
106
+ await seedToken(context, hashAccessToken('tst_other'), {
107
+ id: 'other', name: 'other', profileId: 'someone-else',
108
+ })
109
+
110
+ const result = await invoke(listAccessTokens, context, session())
111
+
112
+ expect(result.items.map((item: any) => item.name).sort()).toEqual(['one', 'two'])
113
+ expect(result.items.every((item: any) => item.hash === undefined)).toBe(true)
114
+ })
115
+
116
+ test('revoking marks the record and is idempotent', async () => {
117
+ const context = await makeTestContext()
118
+ await seedProfile(context)
119
+ await seedToken(context, hashAccessToken('tst_one'), { id: 'one' })
120
+
121
+ await invoke(revokeAccessToken, context, { ...session(), params: { id: 'one' } })
122
+ const first = await context.resource<any>(AUTH_TOKEN_RESOURCE).load('one')
123
+ expect(first.revokedAt).toBeDefined()
124
+
125
+ // A retry is what a lost response looks like; it must not fail.
126
+ await invoke(revokeAccessToken, context, { ...session(), params: { id: 'one' } })
127
+ const second = await context.resource<any>(AUTH_TOKEN_RESOURCE).load('one')
128
+ expect(new Date(second.revokedAt).getTime()).toBe(new Date(first.revokedAt).getTime())
129
+ })
130
+
131
+ test('another profile\'s token answers exactly as an unknown id does', async () => {
132
+ const context = await makeTestContext()
133
+ await seedProfile(context)
134
+ await seedToken(context, hashAccessToken('tst_other'), { id: 'other', profileId: 'someone-else' })
135
+
136
+ await expect(
137
+ invoke(revokeAccessToken, context, { ...session(), params: { id: 'other' } })
138
+ ).rejects.toThrow()
139
+ await expect(
140
+ invoke(revokeAccessToken, context, { ...session(), params: { id: 'nonexistent' } })
141
+ ).rejects.toThrow()
142
+ })
143
+
144
+ test('a token may never revoke a token', async () => {
145
+ const context = await makeTestContext()
146
+ await seedProfile(context)
147
+ await seedToken(context, hashAccessToken('tst_one'), { id: 'one' })
148
+
149
+ await expect(invoke(
150
+ revokeAccessToken, context,
151
+ { ...session({ type: AuthroizationType.AuthToken }), params: { id: 'one' } }
152
+ )).rejects.toThrow()
153
+ })
154
+ })
@@ -0,0 +1,12 @@
1
+ {
2
+ "extends": [
3
+ "@owlmeans/dep-config/tsconfig.base.json",
4
+ "@owlmeans/dep-config/tsconfig.node.json"
5
+ ],
6
+ "compilerOptions": {
7
+ "types": ["bun"],
8
+ "rootDir": "../",
9
+ "noEmit": true
10
+ },
11
+ "include": ["./**/*", "../src/**/*"]
12
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,11 @@
1
+ {
2
+ "extends": [
3
+ "@owlmeans/dep-config/tsconfig.base.json",
4
+ "@owlmeans/dep-config/tsconfig.server.json"
5
+ ],
6
+ "compilerOptions": {
7
+ "rootDir": "./src/",
8
+ "outDir": "./build/"
9
+ },
10
+ "exclude": ["./dist/**/*", "./build/**/*", "./tests/**/*", "./*.ts"]
11
+ }