@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.
- package/README.md +177 -1
- package/agent-meta/manifest.json +3 -3
- package/agent-meta/skills/server-auth-token/SKILL.md +33 -10
- package/build/guard.d.ts +1 -3
- package/build/guard.d.ts.map +1 -1
- package/build/guard.js +37 -14
- package/build/guard.js.map +1 -1
- package/build/handlers/create.d.ts +5 -0
- package/build/handlers/create.d.ts.map +1 -0
- package/build/handlers/create.js +18 -0
- package/build/handlers/create.js.map +1 -0
- package/build/handlers/index.d.ts +11 -11
- package/build/handlers/index.d.ts.map +1 -1
- package/build/handlers/index.js +8 -93
- package/build/handlers/index.js.map +1 -1
- package/build/handlers/issue.d.ts +6 -0
- package/build/handlers/issue.d.ts.map +1 -0
- package/build/handlers/issue.js +42 -0
- package/build/handlers/issue.js.map +1 -0
- package/build/handlers/list.d.ts +3 -0
- package/build/handlers/list.d.ts.map +1 -0
- package/build/handlers/list.js +18 -0
- package/build/handlers/list.js.map +1 -0
- package/build/handlers/refuse.d.ts +19 -0
- package/build/handlers/refuse.d.ts.map +1 -0
- package/build/handlers/refuse.js +20 -0
- package/build/handlers/refuse.js.map +1 -0
- package/build/handlers/revoke.d.ts +17 -0
- package/build/handlers/revoke.d.ts.map +1 -0
- package/build/handlers/revoke.js +35 -0
- package/build/handlers/revoke.js.map +1 -0
- package/build/handlers/types.d.ts +37 -0
- package/build/handlers/types.d.ts.map +1 -0
- package/build/handlers/types.js +2 -0
- package/build/handlers/types.js.map +1 -0
- package/build/handlers/utils/types.d.ts +10 -0
- package/build/handlers/utils/types.d.ts.map +1 -0
- package/build/handlers/utils/types.js +2 -0
- package/build/handlers/utils/types.js.map +1 -0
- package/build/handlers/utils.d.ts +4 -0
- package/build/handlers/utils.d.ts.map +1 -0
- package/build/handlers/utils.js +11 -0
- package/build/handlers/utils.js.map +1 -0
- package/build/hash/types.d.ts +16 -0
- package/build/hash/types.d.ts.map +1 -0
- package/build/hash/types.js +2 -0
- package/build/hash/types.js.map +1 -0
- package/build/hash.d.ts +4 -15
- package/build/hash.d.ts.map +1 -1
- package/build/hash.js +13 -16
- package/build/hash.js.map +1 -1
- package/build/index.d.ts +3 -0
- package/build/index.d.ts.map +1 -1
- package/build/index.js +1 -0
- package/build/index.js.map +1 -1
- package/build/prefix.d.ts +4 -0
- package/build/prefix.d.ts.map +1 -0
- package/build/prefix.js +4 -0
- package/build/prefix.js.map +1 -0
- package/build/types.d.ts +20 -0
- package/build/types.d.ts.map +1 -1
- package/package.json +17 -16
- package/src/guard.ts +39 -16
- package/src/handlers/create.ts +21 -0
- package/src/handlers/index.ts +17 -115
- package/src/handlers/issue.ts +50 -0
- package/src/handlers/list.ts +21 -0
- package/src/handlers/refuse.ts +20 -0
- package/src/handlers/revoke.ts +42 -0
- package/src/handlers/types.ts +40 -0
- package/src/handlers/utils/types.ts +10 -0
- package/src/handlers/utils.ts +18 -0
- package/src/hash/types.ts +16 -0
- package/src/hash.ts +17 -21
- package/src/index.ts +3 -0
- package/src/prefix.ts +6 -0
- package/src/types.ts +21 -0
- package/tests/guard.spec.ts +54 -10
- package/tests/handlers.spec.ts +56 -8
package/tests/guard.spec.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { describe, expect, test } from 'bun:test'
|
|
|
2
2
|
import { AuthroizationType } from '@owlmeans/auth'
|
|
3
3
|
import { GUARD_AUTH_TOKEN } from '@owlmeans/auth-token'
|
|
4
4
|
import type { GuardService } from '@owlmeans/entrypoint'
|
|
5
|
-
import {
|
|
5
|
+
import { tokenHashHelper } from '../src/hash.js'
|
|
6
6
|
import {
|
|
7
7
|
makeTestContext, request, response, seedProfile, seedToken, TEST_ENTITY, TEST_PREFIX, TEST_PROFILE
|
|
8
8
|
} from './context.js'
|
|
@@ -12,7 +12,7 @@ const TOKEN = `${TEST_PREFIX}averylongrandomsecretvalue`
|
|
|
12
12
|
const armed = async (opts = {}) => {
|
|
13
13
|
const context = await makeTestContext(opts)
|
|
14
14
|
await seedProfile(context)
|
|
15
|
-
await seedToken(context, hashAccessToken(TOKEN))
|
|
15
|
+
await seedToken(context, tokenHashHelper.hashAccessToken(TOKEN))
|
|
16
16
|
|
|
17
17
|
return { context, guard: context.service<GuardService>(GUARD_AUTH_TOKEN) }
|
|
18
18
|
}
|
|
@@ -60,7 +60,7 @@ describe('@owlmeans/server-auth-token — what the guard resolves', () => {
|
|
|
60
60
|
test('a token can never outrank its profile', async () => {
|
|
61
61
|
const context = await makeTestContext()
|
|
62
62
|
await seedProfile(context, { scopes: ['project:read'] })
|
|
63
|
-
await seedToken(context, hashAccessToken(TOKEN), { scopes: ['*', 'project:write'] })
|
|
63
|
+
await seedToken(context, tokenHashHelper.hashAccessToken(TOKEN), { scopes: ['*', 'project:write'] })
|
|
64
64
|
const guard = context.service<GuardService>(GUARD_AUTH_TOKEN)
|
|
65
65
|
const res = response()
|
|
66
66
|
|
|
@@ -71,7 +71,7 @@ describe('@owlmeans/server-auth-token — what the guard resolves', () => {
|
|
|
71
71
|
test('refuses a revoked token', async () => {
|
|
72
72
|
const context = await makeTestContext()
|
|
73
73
|
await seedProfile(context)
|
|
74
|
-
await seedToken(context, hashAccessToken(TOKEN), { revokedAt: new Date() })
|
|
74
|
+
await seedToken(context, tokenHashHelper.hashAccessToken(TOKEN), { revokedAt: new Date() })
|
|
75
75
|
const guard = context.service<GuardService>(GUARD_AUTH_TOKEN)
|
|
76
76
|
|
|
77
77
|
expect(await guard.handle(request(`Bearer ${TOKEN}`), response())).toBe(false)
|
|
@@ -80,7 +80,7 @@ describe('@owlmeans/server-auth-token — what the guard resolves', () => {
|
|
|
80
80
|
test('refuses an expired token', async () => {
|
|
81
81
|
const context = await makeTestContext()
|
|
82
82
|
await seedProfile(context)
|
|
83
|
-
await seedToken(context, hashAccessToken(TOKEN), { expiresAt: new Date(Date.now() - 1000) })
|
|
83
|
+
await seedToken(context, tokenHashHelper.hashAccessToken(TOKEN), { expiresAt: new Date(Date.now() - 1000) })
|
|
84
84
|
const guard = context.service<GuardService>(GUARD_AUTH_TOKEN)
|
|
85
85
|
|
|
86
86
|
expect(await guard.handle(request(`Bearer ${TOKEN}`), response())).toBe(false)
|
|
@@ -94,7 +94,7 @@ describe('@owlmeans/server-auth-token — what the guard resolves', () => {
|
|
|
94
94
|
|
|
95
95
|
test('refuses when the profile behind it is gone — revoking a person revokes their tokens', async () => {
|
|
96
96
|
const context = await makeTestContext()
|
|
97
|
-
await seedToken(context, hashAccessToken(TOKEN))
|
|
97
|
+
await seedToken(context, tokenHashHelper.hashAccessToken(TOKEN))
|
|
98
98
|
const guard = context.service<GuardService>(GUARD_AUTH_TOKEN)
|
|
99
99
|
|
|
100
100
|
expect(await guard.handle(request(`Bearer ${TOKEN}`), response())).toBe(false)
|
|
@@ -103,7 +103,7 @@ describe('@owlmeans/server-auth-token — what the guard resolves', () => {
|
|
|
103
103
|
test('refuses when the profile has expired', async () => {
|
|
104
104
|
const context = await makeTestContext()
|
|
105
105
|
await seedProfile(context, { expiresAt: new Date(Date.now() - 1000) })
|
|
106
|
-
await seedToken(context, hashAccessToken(TOKEN))
|
|
106
|
+
await seedToken(context, tokenHashHelper.hashAccessToken(TOKEN))
|
|
107
107
|
const guard = context.service<GuardService>(GUARD_AUTH_TOKEN)
|
|
108
108
|
|
|
109
109
|
expect(await guard.handle(request(`Bearer ${TOKEN}`), response())).toBe(false)
|
|
@@ -116,13 +116,57 @@ describe('@owlmeans/server-auth-token — what the guard resolves', () => {
|
|
|
116
116
|
})
|
|
117
117
|
})
|
|
118
118
|
|
|
119
|
+
describe('@owlmeans/server-auth-token — audience admission', () => {
|
|
120
|
+
test('a token with no audience is admitted by a guard that opted into resource checking', async () => {
|
|
121
|
+
const { guard } = await armed({ resources: ['https://api.example.com'] })
|
|
122
|
+
|
|
123
|
+
expect(await guard.handle(request(`Bearer ${TOKEN}`), response())).toBe(true)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
test('a token scoped to a resource is refused by a guard for a different one', async () => {
|
|
127
|
+
const context = await makeTestContext({ resources: ['https://api.example.com'] })
|
|
128
|
+
await seedProfile(context)
|
|
129
|
+
await seedToken(context, tokenHashHelper.hashAccessToken(TOKEN), { audience: ['https://api.example.com/mcp'] })
|
|
130
|
+
const guard = context.service<GuardService>(GUARD_AUTH_TOKEN)
|
|
131
|
+
|
|
132
|
+
expect(await guard.handle(request(`Bearer ${TOKEN}`), response())).toBe(false)
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
test('a token scoped to a resource is admitted by a guard configured for it', async () => {
|
|
136
|
+
const context = await makeTestContext({ resources: ['https://api.example.com/mcp', 'https://api.example.com'] })
|
|
137
|
+
await seedProfile(context)
|
|
138
|
+
await seedToken(context, tokenHashHelper.hashAccessToken(TOKEN), { audience: ['https://api.example.com/mcp'] })
|
|
139
|
+
const guard = context.service<GuardService>(GUARD_AUTH_TOKEN)
|
|
140
|
+
|
|
141
|
+
expect(await guard.handle(request(`Bearer ${TOKEN}`), response())).toBe(true)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
test('a guard that never opted in ignores audience entirely — full backward compatibility', async () => {
|
|
145
|
+
const context = await makeTestContext()
|
|
146
|
+
await seedProfile(context)
|
|
147
|
+
await seedToken(context, tokenHashHelper.hashAccessToken(TOKEN), { audience: ['https://api.example.com/mcp'] })
|
|
148
|
+
const guard = context.service<GuardService>(GUARD_AUTH_TOKEN)
|
|
149
|
+
|
|
150
|
+
expect(await guard.handle(request(`Bearer ${TOKEN}`), response())).toBe(true)
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
test('a function `resources` option is resolved with the live context, not cached from registration', async () => {
|
|
154
|
+
const context = await makeTestContext({ resources: () => ['https://api.example.com'] })
|
|
155
|
+
await seedProfile(context)
|
|
156
|
+
await seedToken(context, tokenHashHelper.hashAccessToken(TOKEN), { audience: ['https://api.example.com'] })
|
|
157
|
+
const guard = context.service<GuardService>(GUARD_AUTH_TOKEN)
|
|
158
|
+
|
|
159
|
+
expect(await guard.handle(request(`Bearer ${TOKEN}`), response())).toBe(true)
|
|
160
|
+
})
|
|
161
|
+
})
|
|
162
|
+
|
|
119
163
|
describe('@owlmeans/server-auth-token — the hash at rest', () => {
|
|
120
164
|
test('the stored value is not the token', () => {
|
|
121
|
-
const hash = hashAccessToken(TOKEN)
|
|
165
|
+
const hash = tokenHashHelper.hashAccessToken(TOKEN)
|
|
122
166
|
|
|
123
167
|
expect(hash).not.toBe(TOKEN)
|
|
124
168
|
expect(hash).toMatch(/^[0-9a-f]{64}$/)
|
|
125
|
-
expect(hashAccessToken(TOKEN)).toBe(hash)
|
|
126
|
-
expect(hashAccessToken(`${TOKEN}x`)).not.toBe(hash)
|
|
169
|
+
expect(tokenHashHelper.hashAccessToken(TOKEN)).toBe(hash)
|
|
170
|
+
expect(tokenHashHelper.hashAccessToken(`${TOKEN}x`)).not.toBe(hash)
|
|
127
171
|
})
|
|
128
172
|
})
|
package/tests/handlers.spec.ts
CHANGED
|
@@ -7,7 +7,8 @@ import {
|
|
|
7
7
|
listAccessTokens as bindListAccessTokens,
|
|
8
8
|
revokeAccessToken as bindRevokeAccessToken,
|
|
9
9
|
} from '../src/handlers/index.js'
|
|
10
|
-
import {
|
|
10
|
+
import { tokenHashHelper } from '../src/hash.js'
|
|
11
|
+
import { accessTokenIssuerOf } from '../src/handlers/index.js'
|
|
11
12
|
import { makeTestContext, seedProfile, seedToken, TEST_ENTITY, TEST_PREFIX, TEST_PROFILE, TEST_USER } from './context.js'
|
|
12
13
|
|
|
13
14
|
/**
|
|
@@ -26,6 +27,7 @@ const protocols = makeAuthTokenEntrypoints()
|
|
|
26
27
|
const listAccessTokens = bindListAccessTokens(protocols.list)
|
|
27
28
|
const createAccessToken = bindCreateAccessToken(protocols.create)
|
|
28
29
|
const revokeAccessToken = bindRevokeAccessToken(protocols.revoke)
|
|
30
|
+
const revokeByToken = bindRevokeAccessToken(protocols.revoke, { allowAccessTokens: true })
|
|
29
31
|
|
|
30
32
|
const session = (patch: Record<string, unknown> = {}): any => ({
|
|
31
33
|
headers: {}, params: {}, query: {}, body: {},
|
|
@@ -57,7 +59,7 @@ describe('@owlmeans/server-auth-token — minting', () => {
|
|
|
57
59
|
// The view a caller sees carries no hash at all.
|
|
58
60
|
expect((issued.record as unknown as AccessTokenRecord).hash).toBeUndefined()
|
|
59
61
|
|
|
60
|
-
const stored = await context.resource<any>(AUTH_TOKEN_RESOURCE).load({ hash: hashAccessToken(issued.token) })
|
|
62
|
+
const stored = await context.resource<any>(AUTH_TOKEN_RESOURCE).load({ hash: tokenHashHelper.hashAccessToken(issued.token) })
|
|
61
63
|
expect(stored).not.toBeNull()
|
|
62
64
|
expect(stored.hash).not.toBe(issued.token)
|
|
63
65
|
})
|
|
@@ -104,14 +106,38 @@ describe('@owlmeans/server-auth-token — minting', () => {
|
|
|
104
106
|
})
|
|
105
107
|
})
|
|
106
108
|
|
|
109
|
+
describe('@owlmeans/server-auth-token — issueAccessToken (the OAuth server\'s entry point)', () => {
|
|
110
|
+
test('stores the audience it was given, and omits the field when there is none', async () => {
|
|
111
|
+
const context = await makeTestContext()
|
|
112
|
+
const subject = { entityId: TEST_ENTITY, profileId: TEST_PROFILE, userId: TEST_USER, role: AuthRole.User, scopes: ['*'] }
|
|
113
|
+
|
|
114
|
+
const withAudience = await accessTokenIssuerOf(context).issueAccessToken(subject, { name: 'mcp', audience: ['https://api.example.com/mcp'] })
|
|
115
|
+
expect(withAudience.record.audience).toEqual(['https://api.example.com/mcp'])
|
|
116
|
+
|
|
117
|
+
const withoutAudience = await accessTokenIssuerOf(context).issueAccessToken(subject, { name: 'hand-minted' })
|
|
118
|
+
expect(withoutAudience.record.audience).toBeUndefined()
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test('still narrows scopes and clamps the TTL the same way the HTTP handler does', async () => {
|
|
122
|
+
const context = await makeTestContext()
|
|
123
|
+
const subject = { entityId: TEST_ENTITY, profileId: TEST_PROFILE, userId: TEST_USER, role: AuthRole.User, scopes: ['projects:read'] }
|
|
124
|
+
|
|
125
|
+
await expect(accessTokenIssuerOf(context).issueAccessToken(subject, { name: 'x', scopes: ['projects:write'] })).rejects.toThrow()
|
|
126
|
+
|
|
127
|
+
const issued = await accessTokenIssuerOf(context).issueAccessToken(subject, { name: 'x', expiresIn: 60 * 60 * 24 * 400 })
|
|
128
|
+
const ttlMs = new Date(issued.record.expiresAt!).getTime() - Date.now()
|
|
129
|
+
expect(ttlMs).toBeLessThanOrEqual(366 * 24 * 60 * 60 * 1000)
|
|
130
|
+
})
|
|
131
|
+
})
|
|
132
|
+
|
|
107
133
|
describe('@owlmeans/server-auth-token — listing and revoking', () => {
|
|
108
134
|
test('a list shows the caller\'s own tokens and never a hash', async () => {
|
|
109
135
|
const context = await makeTestContext()
|
|
110
136
|
await seedProfile(context)
|
|
111
|
-
await seedToken(context, hashAccessToken('tst_one'), { id: 'one', name: 'one' })
|
|
112
|
-
await seedToken(context, hashAccessToken('tst_two'), { id: 'two', name: 'two' })
|
|
137
|
+
await seedToken(context, tokenHashHelper.hashAccessToken('tst_one'), { id: 'one', name: 'one' })
|
|
138
|
+
await seedToken(context, tokenHashHelper.hashAccessToken('tst_two'), { id: 'two', name: 'two' })
|
|
113
139
|
// Somebody else's token, in the same store.
|
|
114
|
-
await seedToken(context, hashAccessToken('tst_other'), {
|
|
140
|
+
await seedToken(context, tokenHashHelper.hashAccessToken('tst_other'), {
|
|
115
141
|
id: 'other', name: 'other', profileId: 'someone-else',
|
|
116
142
|
})
|
|
117
143
|
|
|
@@ -124,7 +150,7 @@ describe('@owlmeans/server-auth-token — listing and revoking', () => {
|
|
|
124
150
|
test('revoking marks the record and is idempotent', async () => {
|
|
125
151
|
const context = await makeTestContext()
|
|
126
152
|
await seedProfile(context)
|
|
127
|
-
await seedToken(context, hashAccessToken('tst_one'), { id: 'one' })
|
|
153
|
+
await seedToken(context, tokenHashHelper.hashAccessToken('tst_one'), { id: 'one' })
|
|
128
154
|
|
|
129
155
|
await invoke(revokeAccessToken, context, { ...session(), params: { id: 'one' } })
|
|
130
156
|
const first = await context.resource<any>(AUTH_TOKEN_RESOURCE).load('one')
|
|
@@ -139,7 +165,7 @@ describe('@owlmeans/server-auth-token — listing and revoking', () => {
|
|
|
139
165
|
test('another profile\'s token answers exactly as an unknown id does', async () => {
|
|
140
166
|
const context = await makeTestContext()
|
|
141
167
|
await seedProfile(context)
|
|
142
|
-
await seedToken(context, hashAccessToken('tst_other'), { id: 'other', profileId: 'someone-else' })
|
|
168
|
+
await seedToken(context, tokenHashHelper.hashAccessToken('tst_other'), { id: 'other', profileId: 'someone-else' })
|
|
143
169
|
|
|
144
170
|
await expect(
|
|
145
171
|
invoke(revokeAccessToken, context, { ...session(), params: { id: 'other' } })
|
|
@@ -152,11 +178,33 @@ describe('@owlmeans/server-auth-token — listing and revoking', () => {
|
|
|
152
178
|
test('a token may never revoke a token', async () => {
|
|
153
179
|
const context = await makeTestContext()
|
|
154
180
|
await seedProfile(context)
|
|
155
|
-
await seedToken(context, hashAccessToken('tst_one'), { id: 'one' })
|
|
181
|
+
await seedToken(context, tokenHashHelper.hashAccessToken('tst_one'), { id: 'one' })
|
|
156
182
|
|
|
157
183
|
await expect(invoke(
|
|
158
184
|
revokeAccessToken, context,
|
|
159
185
|
{ ...session({ type: AuthroizationType.AuthToken }), params: { id: 'one' } }
|
|
160
186
|
)).rejects.toThrow()
|
|
161
187
|
})
|
|
188
|
+
|
|
189
|
+
test('allowAccessTokens lets a token revoke its own person\'s token — and nothing more', async () => {
|
|
190
|
+
const context = await makeTestContext()
|
|
191
|
+
await seedProfile(context)
|
|
192
|
+
await seedToken(context, tokenHashHelper.hashAccessToken('tst_one'), { id: 'one' })
|
|
193
|
+
await seedToken(context, tokenHashHelper.hashAccessToken('tst_other'), { id: 'other', profileId: 'someone-else' })
|
|
194
|
+
await seedToken(context, tokenHashHelper.hashAccessToken('tst_elsewhere'), { id: 'elsewhere', entityId: 'entity-2' })
|
|
195
|
+
const byToken = session({ type: AuthroizationType.AuthToken })
|
|
196
|
+
|
|
197
|
+
expect(await invoke(revokeByToken, context, { ...byToken, params: { id: 'one' } })).toEqual({ id: 'one' })
|
|
198
|
+
expect((await context.resource<any>(AUTH_TOKEN_RESOURCE).load('one')).revokedAt).toBeDefined()
|
|
199
|
+
|
|
200
|
+
// Another person's token, another organization's, and an unknown id answer alike — untouched.
|
|
201
|
+
for (const id of ['other', 'elsewhere', 'nonexistent']) {
|
|
202
|
+
await expect(invoke(revokeByToken, context, { ...byToken, params: { id } })).rejects.toThrow()
|
|
203
|
+
}
|
|
204
|
+
expect((await context.resource<any>(AUTH_TOKEN_RESOURCE).load('other')).revokedAt).toBeUndefined()
|
|
205
|
+
expect((await context.resource<any>(AUTH_TOKEN_RESOURCE).load('elsewhere')).revokedAt).toBeUndefined()
|
|
206
|
+
|
|
207
|
+
// The option widens only the credential: minting is refused to a token exactly as before.
|
|
208
|
+
await expect(invoke(createAccessToken, context, { ...byToken, body: { name: 'minted' } })).rejects.toThrow()
|
|
209
|
+
})
|
|
162
210
|
})
|