@owlmeans/server-auth-token 0.1.18-rc.30 → 0.1.18-rc.32
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 +1 -1
- package/agent-meta/manifest.json +2 -2
- package/agent-meta/skills/server-auth-token/SKILL.md +6 -6
- package/build/guard.d.ts +19 -3
- package/build/guard.d.ts.map +1 -1
- package/build/guard.js +19 -20
- 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 +9 -52
- package/build/handlers/index.d.ts.map +1 -1
- package/build/handlers/index.js +8 -109
- 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 +7 -0
- package/build/handlers/revoke.d.ts.map +1 -0
- package/build/handlers/revoke.js +25 -0
- package/build/handlers/revoke.js.map +1 -0
- package/build/handlers/types.d.ts +29 -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 +5 -0
- package/build/types.d.ts.map +1 -1
- package/package.json +15 -15
- package/src/guard.ts +21 -24
- package/src/handlers/create.ts +21 -0
- package/src/handlers/index.ts +12 -147
- 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 +30 -0
- package/src/handlers/types.ts +31 -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 +6 -0
- package/tests/guard.spec.ts +14 -14
- package/tests/handlers.spec.ts +13 -13
|
@@ -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,30 @@
|
|
|
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 { tokenRecordUtils } from './utils.js'
|
|
8
|
+
|
|
9
|
+
export const revokeAccessToken = (protocol: AuthTokenEntrypoints['revoke']) => handlers<AuthTokenContext>().params(protocol, async (payload, context, req) => {
|
|
10
|
+
const ctx = context as AuthTokenContext
|
|
11
|
+
refuseTokenAuth(req, 'token-revoke')
|
|
12
|
+
|
|
13
|
+
const entityId = makeEntityScope(req).requireEntityKey()
|
|
14
|
+
const profileId = req.auth?.profileId
|
|
15
|
+
if (profileId == null) throw new AuthForbidden('profile')
|
|
16
|
+
|
|
17
|
+
const record = await tokenRecordUtils.tokens(ctx).load(payload.id)
|
|
18
|
+
// A token of another profile answers exactly as an unknown id does — an owner learns nothing
|
|
19
|
+
// about tokens that are not theirs, not even that one exists.
|
|
20
|
+
if (record == null || record.entityId !== entityId || record.profileId !== profileId) {
|
|
21
|
+
throw new AuthForbidden('token')
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Idempotent: revoking twice is what a retry looks like.
|
|
25
|
+
if (record.revokedAt == null) {
|
|
26
|
+
await tokenRecordUtils.tokens(ctx).save({ ...record, revokedAt: new Date(), updatedAt: new Date() })
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return { id: payload.id }
|
|
30
|
+
})
|
|
@@ -0,0 +1,31 @@
|
|
|
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
|
+
}
|
|
@@ -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,
|
|
1
|
+
import { AUTH_TOKEN_SECRET_BYTES, tokenFormatHelper } from '@owlmeans/auth-token'
|
|
2
2
|
import { base58 } from '@scure/base'
|
|
3
|
-
import { sha256 } from '@noble/hashes/
|
|
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
|
-
|
|
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
|
-
|
|
17
|
-
|
|
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
|
-
|
|
23
|
-
|
|
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 {
|
|
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
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)
|
|
@@ -126,7 +126,7 @@ describe('@owlmeans/server-auth-token — audience admission', () => {
|
|
|
126
126
|
test('a token scoped to a resource is refused by a guard for a different one', async () => {
|
|
127
127
|
const context = await makeTestContext({ resources: ['https://api.example.com'] })
|
|
128
128
|
await seedProfile(context)
|
|
129
|
-
await seedToken(context, hashAccessToken(TOKEN), { audience: ['https://api.example.com/mcp'] })
|
|
129
|
+
await seedToken(context, tokenHashHelper.hashAccessToken(TOKEN), { audience: ['https://api.example.com/mcp'] })
|
|
130
130
|
const guard = context.service<GuardService>(GUARD_AUTH_TOKEN)
|
|
131
131
|
|
|
132
132
|
expect(await guard.handle(request(`Bearer ${TOKEN}`), response())).toBe(false)
|
|
@@ -135,7 +135,7 @@ describe('@owlmeans/server-auth-token — audience admission', () => {
|
|
|
135
135
|
test('a token scoped to a resource is admitted by a guard configured for it', async () => {
|
|
136
136
|
const context = await makeTestContext({ resources: ['https://api.example.com/mcp', 'https://api.example.com'] })
|
|
137
137
|
await seedProfile(context)
|
|
138
|
-
await seedToken(context, hashAccessToken(TOKEN), { audience: ['https://api.example.com/mcp'] })
|
|
138
|
+
await seedToken(context, tokenHashHelper.hashAccessToken(TOKEN), { audience: ['https://api.example.com/mcp'] })
|
|
139
139
|
const guard = context.service<GuardService>(GUARD_AUTH_TOKEN)
|
|
140
140
|
|
|
141
141
|
expect(await guard.handle(request(`Bearer ${TOKEN}`), response())).toBe(true)
|
|
@@ -144,7 +144,7 @@ describe('@owlmeans/server-auth-token — audience admission', () => {
|
|
|
144
144
|
test('a guard that never opted in ignores audience entirely — full backward compatibility', async () => {
|
|
145
145
|
const context = await makeTestContext()
|
|
146
146
|
await seedProfile(context)
|
|
147
|
-
await seedToken(context, hashAccessToken(TOKEN), { audience: ['https://api.example.com/mcp'] })
|
|
147
|
+
await seedToken(context, tokenHashHelper.hashAccessToken(TOKEN), { audience: ['https://api.example.com/mcp'] })
|
|
148
148
|
const guard = context.service<GuardService>(GUARD_AUTH_TOKEN)
|
|
149
149
|
|
|
150
150
|
expect(await guard.handle(request(`Bearer ${TOKEN}`), response())).toBe(true)
|
|
@@ -153,7 +153,7 @@ describe('@owlmeans/server-auth-token — audience admission', () => {
|
|
|
153
153
|
test('a function `resources` option is resolved with the live context, not cached from registration', async () => {
|
|
154
154
|
const context = await makeTestContext({ resources: () => ['https://api.example.com'] })
|
|
155
155
|
await seedProfile(context)
|
|
156
|
-
await seedToken(context, hashAccessToken(TOKEN), { audience: ['https://api.example.com'] })
|
|
156
|
+
await seedToken(context, tokenHashHelper.hashAccessToken(TOKEN), { audience: ['https://api.example.com'] })
|
|
157
157
|
const guard = context.service<GuardService>(GUARD_AUTH_TOKEN)
|
|
158
158
|
|
|
159
159
|
expect(await guard.handle(request(`Bearer ${TOKEN}`), response())).toBe(true)
|
|
@@ -162,11 +162,11 @@ describe('@owlmeans/server-auth-token — audience admission', () => {
|
|
|
162
162
|
|
|
163
163
|
describe('@owlmeans/server-auth-token — the hash at rest', () => {
|
|
164
164
|
test('the stored value is not the token', () => {
|
|
165
|
-
const hash = hashAccessToken(TOKEN)
|
|
165
|
+
const hash = tokenHashHelper.hashAccessToken(TOKEN)
|
|
166
166
|
|
|
167
167
|
expect(hash).not.toBe(TOKEN)
|
|
168
168
|
expect(hash).toMatch(/^[0-9a-f]{64}$/)
|
|
169
|
-
expect(hashAccessToken(TOKEN)).toBe(hash)
|
|
170
|
-
expect(hashAccessToken(`${TOKEN}x`)).not.toBe(hash)
|
|
169
|
+
expect(tokenHashHelper.hashAccessToken(TOKEN)).toBe(hash)
|
|
170
|
+
expect(tokenHashHelper.hashAccessToken(`${TOKEN}x`)).not.toBe(hash)
|
|
171
171
|
})
|
|
172
172
|
})
|
package/tests/handlers.spec.ts
CHANGED
|
@@ -7,8 +7,8 @@ import {
|
|
|
7
7
|
listAccessTokens as bindListAccessTokens,
|
|
8
8
|
revokeAccessToken as bindRevokeAccessToken,
|
|
9
9
|
} from '../src/handlers/index.js'
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
10
|
+
import { tokenHashHelper } from '../src/hash.js'
|
|
11
|
+
import { accessTokenIssuerOf } from '../src/handlers/index.js'
|
|
12
12
|
import { makeTestContext, seedProfile, seedToken, TEST_ENTITY, TEST_PREFIX, TEST_PROFILE, TEST_USER } from './context.js'
|
|
13
13
|
|
|
14
14
|
/**
|
|
@@ -58,7 +58,7 @@ describe('@owlmeans/server-auth-token — minting', () => {
|
|
|
58
58
|
// The view a caller sees carries no hash at all.
|
|
59
59
|
expect((issued.record as unknown as AccessTokenRecord).hash).toBeUndefined()
|
|
60
60
|
|
|
61
|
-
const stored = await context.resource<any>(AUTH_TOKEN_RESOURCE).load({ hash: hashAccessToken(issued.token) })
|
|
61
|
+
const stored = await context.resource<any>(AUTH_TOKEN_RESOURCE).load({ hash: tokenHashHelper.hashAccessToken(issued.token) })
|
|
62
62
|
expect(stored).not.toBeNull()
|
|
63
63
|
expect(stored.hash).not.toBe(issued.token)
|
|
64
64
|
})
|
|
@@ -110,10 +110,10 @@ describe('@owlmeans/server-auth-token — issueAccessToken (the OAuth server\'s
|
|
|
110
110
|
const context = await makeTestContext()
|
|
111
111
|
const subject = { entityId: TEST_ENTITY, profileId: TEST_PROFILE, userId: TEST_USER, role: AuthRole.User, scopes: ['*'] }
|
|
112
112
|
|
|
113
|
-
const withAudience = await issueAccessToken(
|
|
113
|
+
const withAudience = await accessTokenIssuerOf(context).issueAccessToken(subject, { name: 'mcp', audience: ['https://api.example.com/mcp'] })
|
|
114
114
|
expect(withAudience.record.audience).toEqual(['https://api.example.com/mcp'])
|
|
115
115
|
|
|
116
|
-
const withoutAudience = await issueAccessToken(
|
|
116
|
+
const withoutAudience = await accessTokenIssuerOf(context).issueAccessToken(subject, { name: 'hand-minted' })
|
|
117
117
|
expect(withoutAudience.record.audience).toBeUndefined()
|
|
118
118
|
})
|
|
119
119
|
|
|
@@ -121,9 +121,9 @@ describe('@owlmeans/server-auth-token — issueAccessToken (the OAuth server\'s
|
|
|
121
121
|
const context = await makeTestContext()
|
|
122
122
|
const subject = { entityId: TEST_ENTITY, profileId: TEST_PROFILE, userId: TEST_USER, role: AuthRole.User, scopes: ['projects:read'] }
|
|
123
123
|
|
|
124
|
-
await expect(issueAccessToken(
|
|
124
|
+
await expect(accessTokenIssuerOf(context).issueAccessToken(subject, { name: 'x', scopes: ['projects:write'] })).rejects.toThrow()
|
|
125
125
|
|
|
126
|
-
const issued = await issueAccessToken(
|
|
126
|
+
const issued = await accessTokenIssuerOf(context).issueAccessToken(subject, { name: 'x', expiresIn: 60 * 60 * 24 * 400 })
|
|
127
127
|
const ttlMs = new Date(issued.record.expiresAt!).getTime() - Date.now()
|
|
128
128
|
expect(ttlMs).toBeLessThanOrEqual(366 * 24 * 60 * 60 * 1000)
|
|
129
129
|
})
|
|
@@ -133,10 +133,10 @@ describe('@owlmeans/server-auth-token — listing and revoking', () => {
|
|
|
133
133
|
test('a list shows the caller\'s own tokens and never a hash', async () => {
|
|
134
134
|
const context = await makeTestContext()
|
|
135
135
|
await seedProfile(context)
|
|
136
|
-
await seedToken(context, hashAccessToken('tst_one'), { id: 'one', name: 'one' })
|
|
137
|
-
await seedToken(context, hashAccessToken('tst_two'), { id: 'two', name: 'two' })
|
|
136
|
+
await seedToken(context, tokenHashHelper.hashAccessToken('tst_one'), { id: 'one', name: 'one' })
|
|
137
|
+
await seedToken(context, tokenHashHelper.hashAccessToken('tst_two'), { id: 'two', name: 'two' })
|
|
138
138
|
// Somebody else's token, in the same store.
|
|
139
|
-
await seedToken(context, hashAccessToken('tst_other'), {
|
|
139
|
+
await seedToken(context, tokenHashHelper.hashAccessToken('tst_other'), {
|
|
140
140
|
id: 'other', name: 'other', profileId: 'someone-else',
|
|
141
141
|
})
|
|
142
142
|
|
|
@@ -149,7 +149,7 @@ describe('@owlmeans/server-auth-token — listing and revoking', () => {
|
|
|
149
149
|
test('revoking marks the record and is idempotent', async () => {
|
|
150
150
|
const context = await makeTestContext()
|
|
151
151
|
await seedProfile(context)
|
|
152
|
-
await seedToken(context, hashAccessToken('tst_one'), { id: 'one' })
|
|
152
|
+
await seedToken(context, tokenHashHelper.hashAccessToken('tst_one'), { id: 'one' })
|
|
153
153
|
|
|
154
154
|
await invoke(revokeAccessToken, context, { ...session(), params: { id: 'one' } })
|
|
155
155
|
const first = await context.resource<any>(AUTH_TOKEN_RESOURCE).load('one')
|
|
@@ -164,7 +164,7 @@ describe('@owlmeans/server-auth-token — listing and revoking', () => {
|
|
|
164
164
|
test('another profile\'s token answers exactly as an unknown id does', async () => {
|
|
165
165
|
const context = await makeTestContext()
|
|
166
166
|
await seedProfile(context)
|
|
167
|
-
await seedToken(context, hashAccessToken('tst_other'), { id: 'other', profileId: 'someone-else' })
|
|
167
|
+
await seedToken(context, tokenHashHelper.hashAccessToken('tst_other'), { id: 'other', profileId: 'someone-else' })
|
|
168
168
|
|
|
169
169
|
await expect(
|
|
170
170
|
invoke(revokeAccessToken, context, { ...session(), params: { id: 'other' } })
|
|
@@ -177,7 +177,7 @@ describe('@owlmeans/server-auth-token — listing and revoking', () => {
|
|
|
177
177
|
test('a token may never revoke a token', async () => {
|
|
178
178
|
const context = await makeTestContext()
|
|
179
179
|
await seedProfile(context)
|
|
180
|
-
await seedToken(context, hashAccessToken('tst_one'), { id: 'one' })
|
|
180
|
+
await seedToken(context, tokenHashHelper.hashAccessToken('tst_one'), { id: 'one' })
|
|
181
181
|
|
|
182
182
|
await expect(invoke(
|
|
183
183
|
revokeAccessToken, context,
|