@baseworks/auth 0.2.5 → 1.0.2
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 +249 -84
- package/dist/chunk-3XJIAYEG.js +88 -0
- package/dist/{chunk-C4V5LCFA.js → chunk-GXDYEKX7.js} +3 -3
- package/dist/cli-auth.d.ts +7 -1
- package/dist/cli-auth.js +1 -1
- package/dist/cli.d.ts +15 -1
- package/dist/cli.js +57 -15
- package/dist/hono.d.ts +12 -5
- package/dist/hono.js +41 -13
- package/dist/identity.d.ts +95 -0
- package/dist/identity.js +16 -0
- package/dist/index.d.ts +0 -1
- package/dist/index.js +5 -25
- package/dist/jwt.d.ts +1 -0
- package/package.json +15 -19
- package/src/__tests__/identity.test.ts +238 -0
- package/src/cli-auth.ts +13 -4
- package/src/cli.ts +103 -16
- package/src/hono.ts +69 -15
- package/src/identity.ts +224 -0
- package/src/index.ts +3 -17
- package/src/jwt.ts +1 -0
- package/dist/chunk-3NJASEF4.js +0 -69
- package/dist/chunk-JS5Y2DTW.js +0 -387
- package/dist/edge.d.ts +0 -17
- package/dist/edge.js +0 -145
- package/dist/session.d.ts +0 -42
- package/dist/session.js +0 -27
- package/dist/url-helpers.d.ts +0 -11
- package/dist/url-helpers.js +0 -22
- package/src/edge.ts +0 -174
- package/src/session.ts +0 -512
- package/src/url-helpers.ts +0 -66
package/dist/hono.d.ts
CHANGED
|
@@ -5,17 +5,24 @@ interface ServiceAuthUser {
|
|
|
5
5
|
userId: string;
|
|
6
6
|
orgId?: string;
|
|
7
7
|
role?: string;
|
|
8
|
-
|
|
8
|
+
email?: string;
|
|
9
|
+
tokenType: 'user' | 'service' | 'assistant';
|
|
9
10
|
}
|
|
10
11
|
declare module 'hono' {
|
|
11
12
|
interface ContextVariableMap {
|
|
12
13
|
auth: ServiceAuthUser;
|
|
13
14
|
}
|
|
14
15
|
}
|
|
16
|
+
/** Tests change the environment between cases. */
|
|
17
|
+
declare function resetAuthVerifier(): void;
|
|
15
18
|
/**
|
|
16
|
-
* Hono middleware — verifies
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
+
* Hono middleware — verifies the realm's identity token from
|
|
20
|
+
* `Authorization: Bearer` and sets `c.var.auth`.
|
|
21
|
+
*
|
|
22
|
+
* ES256 verified against the realm's published JWKS, plus HS256 while a legacy
|
|
23
|
+
* secret is configured. The shared secret is a migration door: any service
|
|
24
|
+
* holding it can also MINT tokens, including one claiming to be another user,
|
|
25
|
+
* so it should be removed once the realm issues ES256 everywhere.
|
|
19
26
|
*/
|
|
20
27
|
declare function requireAuth(c: Context, next: Next): Promise<void | (Response & hono.TypedResponse<{
|
|
21
28
|
error: string;
|
|
@@ -23,4 +30,4 @@ declare function requireAuth(c: Context, next: Next): Promise<void | (Response &
|
|
|
23
30
|
error: string;
|
|
24
31
|
}, 500, "json">)>;
|
|
25
32
|
|
|
26
|
-
export { type ServiceAuthUser, requireAuth };
|
|
33
|
+
export { type ServiceAuthUser, requireAuth, resetAuthVerifier };
|
package/dist/hono.js
CHANGED
|
@@ -1,24 +1,52 @@
|
|
|
1
1
|
import {
|
|
2
|
-
|
|
3
|
-
} from "./chunk-
|
|
2
|
+
verifier
|
|
3
|
+
} from "./chunk-3XJIAYEG.js";
|
|
4
4
|
|
|
5
5
|
// src/hono.ts
|
|
6
|
+
function fromEnv(env) {
|
|
7
|
+
const jwksUri = env["AUTH_JWKS_URI"];
|
|
8
|
+
const legacy = env["JWT_SECRET"];
|
|
9
|
+
if (!jwksUri && !legacy) {
|
|
10
|
+
return { error: "neither AUTH_JWKS_URI nor JWT_SECRET is configured" };
|
|
11
|
+
}
|
|
12
|
+
return verifier({
|
|
13
|
+
...jwksUri ? { jwksUri } : {},
|
|
14
|
+
...env["AUTH_ISSUER"] ? { issuer: env["AUTH_ISSUER"] } : {},
|
|
15
|
+
...env["AUTH_AUDIENCE"] ? { audience: env["AUTH_AUDIENCE"] } : {},
|
|
16
|
+
...legacy ? { legacyHs256Secret: legacy } : {}
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
var cachedVerifier;
|
|
20
|
+
function resetAuthVerifier() {
|
|
21
|
+
cachedVerifier = void 0;
|
|
22
|
+
}
|
|
23
|
+
function claimsToUser(claims) {
|
|
24
|
+
const type = claims.type;
|
|
25
|
+
return {
|
|
26
|
+
userId: String(claims.sub ?? ""),
|
|
27
|
+
orgId: claims["org_id"],
|
|
28
|
+
role: claims["role"],
|
|
29
|
+
email: claims["email"],
|
|
30
|
+
// Anything unrecognised is treated as a user token: `service` and
|
|
31
|
+
// `assistant` open doors a plain user token must not, so they are named
|
|
32
|
+
// explicitly and everything else falls to the least privileged reading.
|
|
33
|
+
tokenType: type === "service" ? "service" : type === "assistant" ? "assistant" : "user"
|
|
34
|
+
};
|
|
35
|
+
}
|
|
6
36
|
async function requireAuth(c, next) {
|
|
7
37
|
const header = c.req.header("Authorization");
|
|
8
38
|
if (!header?.startsWith("Bearer ")) return c.json({ error: "Unauthorized" }, 401);
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
39
|
+
cachedVerifier ??= fromEnv(process.env);
|
|
40
|
+
if (typeof cachedVerifier !== "function") {
|
|
41
|
+
console.error(`[auth] ${cachedVerifier.error}`);
|
|
42
|
+
return c.json({ error: "auth is not configured" }, 500);
|
|
43
|
+
}
|
|
44
|
+
const claims = await cachedVerifier(header.slice(7));
|
|
13
45
|
if (!claims) return c.json({ error: "Unauthorized" }, 401);
|
|
14
|
-
c.set("auth",
|
|
15
|
-
userId: String(claims["sub"] ?? ""),
|
|
16
|
-
orgId: claims["org_id"],
|
|
17
|
-
role: claims["role"],
|
|
18
|
-
tokenType: claims["type"] === "service" ? "service" : "user"
|
|
19
|
-
});
|
|
46
|
+
c.set("auth", claimsToUser(claims));
|
|
20
47
|
return next();
|
|
21
48
|
}
|
|
22
49
|
export {
|
|
23
|
-
requireAuth
|
|
50
|
+
requireAuth,
|
|
51
|
+
resetAuthVerifier
|
|
24
52
|
};
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { JWK, JSONWebKeySet } from 'jose';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Realm identity tokens: minting, publication and verification.
|
|
5
|
+
*
|
|
6
|
+
* One realm has one issuer — its auth-service — and one signing key pair. Every
|
|
7
|
+
* other service in the realm (org, iam, billing, pay, and each product API)
|
|
8
|
+
* verifies with the PUBLIC key it fetches from `/.well-known/jwks.json`, and
|
|
9
|
+
* therefore cannot mint anything.
|
|
10
|
+
*
|
|
11
|
+
* That asymmetry is the whole point. With a shared HMAC secret, every service
|
|
12
|
+
* holding it can also *issue* tokens — including one that claims to be another
|
|
13
|
+
* user. This is not theoretical: a product API was found minting
|
|
14
|
+
* `{ sub: <some other user>, type: 'user' }` to write a membership on that
|
|
15
|
+
* user's behalf. Under ES256 that is impossible by construction: the private
|
|
16
|
+
* key never leaves auth-service.
|
|
17
|
+
*
|
|
18
|
+
* Claims: `iss`, `aud`, `sub`, `iat`, `exp`, `jti`, `type`. Org and role are
|
|
19
|
+
* deliberately absent — membership changes while a token lives, so a token that
|
|
20
|
+
* carries a role goes stale and lies. Consumers ask the org service instead.
|
|
21
|
+
*
|
|
22
|
+
* HS256 stays available during migration; see `verifier()` below.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
declare const IDENTITY_ALG = "ES256";
|
|
26
|
+
/** `human` signs in; `service` is a service acting as itself; `assistant` is a narrowed token. */
|
|
27
|
+
type IdentityType = 'human' | 'service' | 'assistant';
|
|
28
|
+
interface IdentityClaims {
|
|
29
|
+
sub: string;
|
|
30
|
+
type: IdentityType;
|
|
31
|
+
iss?: string;
|
|
32
|
+
aud?: string;
|
|
33
|
+
exp?: number;
|
|
34
|
+
iat?: number;
|
|
35
|
+
jti?: string;
|
|
36
|
+
/** Narrowed tokens carry the org they are scoped to; identity tokens do not. */
|
|
37
|
+
org_id?: string;
|
|
38
|
+
scope?: string;
|
|
39
|
+
[key: string]: unknown;
|
|
40
|
+
}
|
|
41
|
+
interface SigningKey {
|
|
42
|
+
privateKey: CryptoKey;
|
|
43
|
+
/** Published in the JWKS and in every token header, so verifiers pick the right key. */
|
|
44
|
+
kid: string;
|
|
45
|
+
publicJwk: JWK;
|
|
46
|
+
}
|
|
47
|
+
/** Load the signing key pair from a PKCS#8 private key. */
|
|
48
|
+
declare function loadSigningKey(privateKeyPem: string, kid?: string): Promise<SigningKey>;
|
|
49
|
+
/** Load a public key (SPKI PEM) for publication only — a retired key during rotation. */
|
|
50
|
+
declare function loadPublicKey(publicKeyPem: string, kid?: string): Promise<JWK>;
|
|
51
|
+
/**
|
|
52
|
+
* The document served at `/.well-known/jwks.json`.
|
|
53
|
+
*
|
|
54
|
+
* Rotation works by publishing both keys for longer than a token lives: add the
|
|
55
|
+
* new key, sign with it, keep the old one listed until every token signed by it
|
|
56
|
+
* has expired, then drop it.
|
|
57
|
+
*/
|
|
58
|
+
declare function jwks(signing: SigningKey, retired?: JWK[]): JSONWebKeySet;
|
|
59
|
+
interface MintOptions {
|
|
60
|
+
issuer: string;
|
|
61
|
+
audience: string;
|
|
62
|
+
expiresInSeconds: number;
|
|
63
|
+
}
|
|
64
|
+
declare function mint(key: SigningKey, claims: IdentityClaims, opts: MintOptions): Promise<string>;
|
|
65
|
+
interface VerifierOptions {
|
|
66
|
+
/** Where to fetch the realm's public keys. jose caches and refetches on an unknown `kid`. */
|
|
67
|
+
jwksUri?: string;
|
|
68
|
+
/** A fixed key set instead of a URL — for tests, or a service that ships with the realm. */
|
|
69
|
+
jwks?: JSONWebKeySet;
|
|
70
|
+
issuer?: string;
|
|
71
|
+
audience?: string;
|
|
72
|
+
/**
|
|
73
|
+
* Accept HS256 tokens signed with this shared secret, in addition to ES256.
|
|
74
|
+
*
|
|
75
|
+
* Migration only. While it is set, any holder of the secret can still mint
|
|
76
|
+
* tokens for this realm — including tokens impersonating a user — so it must
|
|
77
|
+
* be removed once every issuer has moved to ES256.
|
|
78
|
+
*/
|
|
79
|
+
legacyHs256Secret?: string;
|
|
80
|
+
}
|
|
81
|
+
interface Verifier {
|
|
82
|
+
(token: string): Promise<IdentityClaims | null>;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Build a verifier for the realm.
|
|
86
|
+
*
|
|
87
|
+
* Returns `null` rather than throwing on every rejection: a caller answering an
|
|
88
|
+
* HTTP request wants one 401 path, not a taxonomy of crypto errors. Reasons
|
|
89
|
+
* worth acting on (an unreachable JWKS, say) surface through `onError`.
|
|
90
|
+
*/
|
|
91
|
+
declare function verifier(opts: VerifierOptions & {
|
|
92
|
+
onError?: (err: unknown) => void;
|
|
93
|
+
}): Verifier;
|
|
94
|
+
|
|
95
|
+
export { IDENTITY_ALG, type IdentityClaims, type IdentityType, type MintOptions, type SigningKey, type Verifier, type VerifierOptions, jwks, loadPublicKey, loadSigningKey, mint, verifier };
|
package/dist/identity.js
ADDED
package/dist/index.d.ts
CHANGED
|
@@ -4,4 +4,3 @@ export { OidcAuthUrlConfig, PkceChallenge, buildOidcAuthUrl, generatePkce } from
|
|
|
4
4
|
export { CliAuthResult, CliAuthStart, PollOptions, pollCliAuth, startCliAuth } from './cli-auth.js';
|
|
5
5
|
export { hashToken, looksLikeJwt, stripBearer } from './token.js';
|
|
6
6
|
export { JwtClaims, signHs256Jwt, verifyHs256Jwt } from './jwt.js';
|
|
7
|
-
export { buildAccountProfileUrl, buildAuthLoginUrl, buildAuthLogoutUrl, buildPublicUrl, getAccountPublicUrl, getAppBasePath, getAuthPublicUrl, getPublicSiteUrl, normalizeUrlLike } from './url-helpers.js';
|
package/dist/index.js
CHANGED
|
@@ -4,49 +4,29 @@ import {
|
|
|
4
4
|
import {
|
|
5
5
|
verifyOidcToken
|
|
6
6
|
} from "./chunk-BL74TFCV.js";
|
|
7
|
+
import {
|
|
8
|
+
buildOidcAuthUrl,
|
|
9
|
+
generatePkce
|
|
10
|
+
} from "./chunk-34E5PYNA.js";
|
|
7
11
|
import {
|
|
8
12
|
pollCliAuth,
|
|
9
13
|
startCliAuth
|
|
10
|
-
} from "./chunk-
|
|
14
|
+
} from "./chunk-GXDYEKX7.js";
|
|
11
15
|
import {
|
|
12
16
|
hashToken,
|
|
13
17
|
looksLikeJwt,
|
|
14
18
|
stripBearer
|
|
15
19
|
} from "./chunk-VBIQJKUU.js";
|
|
16
|
-
import {
|
|
17
|
-
buildAccountProfileUrl,
|
|
18
|
-
buildAuthLoginUrl,
|
|
19
|
-
buildAuthLogoutUrl,
|
|
20
|
-
buildPublicUrl,
|
|
21
|
-
getAccountPublicUrl,
|
|
22
|
-
getAppBasePath,
|
|
23
|
-
getAuthPublicUrl,
|
|
24
|
-
getPublicSiteUrl,
|
|
25
|
-
normalizeUrlLike
|
|
26
|
-
} from "./chunk-3NJASEF4.js";
|
|
27
20
|
import {
|
|
28
21
|
signHs256Jwt,
|
|
29
22
|
verifyHs256Jwt
|
|
30
23
|
} from "./chunk-OD5XLL3O.js";
|
|
31
|
-
import {
|
|
32
|
-
buildOidcAuthUrl,
|
|
33
|
-
generatePkce
|
|
34
|
-
} from "./chunk-34E5PYNA.js";
|
|
35
24
|
export {
|
|
36
|
-
buildAccountProfileUrl,
|
|
37
|
-
buildAuthLoginUrl,
|
|
38
|
-
buildAuthLogoutUrl,
|
|
39
25
|
buildOidcAuthUrl,
|
|
40
|
-
buildPublicUrl,
|
|
41
26
|
createOidcHumanResolver,
|
|
42
27
|
generatePkce,
|
|
43
|
-
getAccountPublicUrl,
|
|
44
|
-
getAppBasePath,
|
|
45
|
-
getAuthPublicUrl,
|
|
46
|
-
getPublicSiteUrl,
|
|
47
28
|
hashToken,
|
|
48
29
|
looksLikeJwt,
|
|
49
|
-
normalizeUrlLike,
|
|
50
30
|
pollCliAuth,
|
|
51
31
|
signHs256Jwt,
|
|
52
32
|
startCliAuth,
|
package/dist/jwt.d.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@baseworks/auth",
|
|
3
|
-
"version": "0.2
|
|
3
|
+
"version": "1.0.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": "./dist/index.js",
|
|
@@ -10,43 +10,39 @@
|
|
|
10
10
|
"./cli-auth": "./dist/cli-auth.js",
|
|
11
11
|
"./cli": "./dist/cli.js",
|
|
12
12
|
"./token": "./dist/token.js",
|
|
13
|
-
"./session": "./dist/session.js",
|
|
14
|
-
"./url-helpers": "./dist/url-helpers.js",
|
|
15
|
-
"./edge": "./dist/edge.js",
|
|
16
13
|
"./jwt": "./dist/jwt.js",
|
|
14
|
+
"./identity": "./dist/identity.js",
|
|
17
15
|
"./hono": "./dist/hono.js"
|
|
18
16
|
},
|
|
19
17
|
"files": [
|
|
20
18
|
"dist",
|
|
21
19
|
"src"
|
|
22
20
|
],
|
|
23
|
-
"scripts": {
|
|
24
|
-
"build": "tsup",
|
|
25
|
-
"typecheck": "tsc --noEmit"
|
|
26
|
-
},
|
|
27
21
|
"dependencies": {
|
|
28
22
|
"@baseworks/core": "^0.2.1",
|
|
29
|
-
"
|
|
23
|
+
"@baseworks/cli": "^0.2.1",
|
|
24
|
+
"jose": "^6.2.3",
|
|
25
|
+
"commander": "^15.0.0"
|
|
30
26
|
},
|
|
31
27
|
"peerDependencies": {
|
|
32
|
-
"hono": ">=4.0.0"
|
|
33
|
-
"next": ">=15.0.0"
|
|
28
|
+
"hono": ">=4.0.0"
|
|
34
29
|
},
|
|
35
30
|
"peerDependenciesMeta": {
|
|
36
|
-
"next": {
|
|
37
|
-
"optional": true
|
|
38
|
-
},
|
|
39
31
|
"hono": {
|
|
40
32
|
"optional": true
|
|
41
33
|
}
|
|
42
34
|
},
|
|
43
35
|
"devDependencies": {
|
|
44
|
-
"@baseworks/cli": "workspace:*",
|
|
45
|
-
"@baseworks/tsconfig": "workspace:*",
|
|
46
36
|
"@types/node": "^26.0.1",
|
|
47
|
-
"commander": "^15.0.0",
|
|
48
37
|
"hono": "^4.12.27",
|
|
49
38
|
"tsup": "^8.5.1",
|
|
50
|
-
"typescript": "^5.6.0"
|
|
39
|
+
"typescript": "^5.6.0",
|
|
40
|
+
"vitest": "^4.1.9",
|
|
41
|
+
"@baseworks/tsconfig": "0.1.0"
|
|
42
|
+
},
|
|
43
|
+
"scripts": {
|
|
44
|
+
"build": "tsup",
|
|
45
|
+
"typecheck": "tsc --noEmit",
|
|
46
|
+
"test": "vitest run"
|
|
51
47
|
}
|
|
52
|
-
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { exportPKCS8, exportSPKI, generateKeyPair } from 'jose'
|
|
3
|
+
import { jwks, loadPublicKey, loadSigningKey, mint, verifier } from '../identity.js'
|
|
4
|
+
|
|
5
|
+
const ISS = 'https://auth.example.test'
|
|
6
|
+
const AUD = 'example-realm'
|
|
7
|
+
|
|
8
|
+
async function freshKey() {
|
|
9
|
+
const { privateKey, publicKey } = await generateKeyPair('ES256', { extractable: true })
|
|
10
|
+
return { pkcs8: await exportPKCS8(privateKey), spki: await exportSPKI(publicKey) }
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe('identity tokens', () => {
|
|
14
|
+
it('mints and verifies a token against the published JWKS', async () => {
|
|
15
|
+
const { pkcs8 } = await freshKey()
|
|
16
|
+
const key = await loadSigningKey(pkcs8)
|
|
17
|
+
const token = await mint(key, { sub: 'usr_1', type: 'human' }, {
|
|
18
|
+
issuer: ISS, audience: AUD, expiresInSeconds: 60,
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
const verify = verifier({ jwks: jwks(key), issuer: ISS, audience: AUD })
|
|
22
|
+
const claims = await verify(token)
|
|
23
|
+
|
|
24
|
+
expect(claims?.sub).toBe('usr_1')
|
|
25
|
+
expect(claims?.type).toBe('human')
|
|
26
|
+
expect(claims?.iss).toBe(ISS)
|
|
27
|
+
expect(claims?.aud).toBe(AUD)
|
|
28
|
+
expect(claims?.jti).toBeTruthy()
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('never publishes the private half', async () => {
|
|
32
|
+
const { pkcs8 } = await freshKey()
|
|
33
|
+
const key = await loadSigningKey(pkcs8)
|
|
34
|
+
const published = JSON.stringify(jwks(key))
|
|
35
|
+
|
|
36
|
+
expect(published).not.toContain('"d"')
|
|
37
|
+
expect(key.publicJwk.kid).toBeTruthy()
|
|
38
|
+
expect(key.publicJwk.alg).toBe('ES256')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('rejects a token signed by another realm', async () => {
|
|
42
|
+
const mine = await loadSigningKey((await freshKey()).pkcs8)
|
|
43
|
+
const theirs = await loadSigningKey((await freshKey()).pkcs8)
|
|
44
|
+
|
|
45
|
+
const token = await mint(theirs, { sub: 'usr_1', type: 'human' }, {
|
|
46
|
+
issuer: ISS, audience: AUD, expiresInSeconds: 60,
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
expect(await verifier({ jwks: jwks(mine), issuer: ISS, audience: AUD })(token)).toBeNull()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('rejects a wrong audience and a wrong issuer', async () => {
|
|
53
|
+
const key = await loadSigningKey((await freshKey()).pkcs8)
|
|
54
|
+
const token = await mint(key, { sub: 'usr_1', type: 'human' }, {
|
|
55
|
+
issuer: ISS, audience: 'other-realm', expiresInSeconds: 60,
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
expect(await verifier({ jwks: jwks(key), issuer: ISS, audience: AUD })(token)).toBeNull()
|
|
59
|
+
expect(await verifier({ jwks: jwks(key), issuer: 'https://elsewhere.test', audience: 'other-realm' })(token)).toBeNull()
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('rejects an expired token', async () => {
|
|
63
|
+
const key = await loadSigningKey((await freshKey()).pkcs8)
|
|
64
|
+
const token = await mint(key, { sub: 'usr_1', type: 'human' }, {
|
|
65
|
+
issuer: ISS, audience: AUD, expiresInSeconds: -1,
|
|
66
|
+
})
|
|
67
|
+
expect(await verifier({ jwks: jwks(key), issuer: ISS, audience: AUD })(token)).toBeNull()
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('keeps a retired key verifiable while it is still published', async () => {
|
|
71
|
+
const old = await loadSigningKey((await freshKey()).pkcs8)
|
|
72
|
+
const oldSpki = (await freshKey()).spki // ayri bir anahtar: yayinlanmamis olan
|
|
73
|
+
const current = await loadSigningKey((await freshKey()).pkcs8)
|
|
74
|
+
|
|
75
|
+
const tokenFromOld = await mint(old, { sub: 'usr_1', type: 'human' }, {
|
|
76
|
+
issuer: ISS, audience: AUD, expiresInSeconds: 60,
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
const withOld = verifier({ jwks: jwks(current, [old.publicJwk]), issuer: ISS, audience: AUD })
|
|
80
|
+
expect((await withOld(tokenFromOld))?.sub).toBe('usr_1')
|
|
81
|
+
|
|
82
|
+
// Yayindan dusunce ayni token artik gecmez.
|
|
83
|
+
const withoutOld = verifier({ jwks: jwks(current), issuer: ISS, audience: AUD })
|
|
84
|
+
expect(await withoutOld(tokenFromOld)).toBeNull()
|
|
85
|
+
|
|
86
|
+
// Emeklilikte yalniz public anahtar elde olur; PEM'den de yuklenebilmeli.
|
|
87
|
+
expect((await loadPublicKey(oldSpki)).kid).toBeTruthy()
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('accepts base64-wrapped PEM, because env vars eat newlines', async () => {
|
|
91
|
+
const { pkcs8 } = await freshKey()
|
|
92
|
+
const key = await loadSigningKey(Buffer.from(pkcs8).toString('base64'))
|
|
93
|
+
const token = await mint(key, { sub: 'usr_1', type: 'human' }, {
|
|
94
|
+
issuer: ISS, audience: AUD, expiresInSeconds: 60,
|
|
95
|
+
})
|
|
96
|
+
expect((await verifier({ jwks: jwks(key), issuer: ISS, audience: AUD })(token))?.sub).toBe('usr_1')
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
describe('HS256 migration mode', () => {
|
|
101
|
+
const secret = 'test-secret-32-chars-long-enough!'
|
|
102
|
+
|
|
103
|
+
it('accepts both an ES256 token and a legacy HS256 one', async () => {
|
|
104
|
+
const { signHs256Jwt } = await import('../jwt.js')
|
|
105
|
+
const key = await loadSigningKey((await freshKey()).pkcs8)
|
|
106
|
+
const verify = verifier({ jwks: jwks(key), issuer: ISS, audience: AUD, legacyHs256Secret: secret })
|
|
107
|
+
|
|
108
|
+
const modern = await mint(key, { sub: 'usr_1', type: 'human' }, {
|
|
109
|
+
issuer: ISS, audience: AUD, expiresInSeconds: 60,
|
|
110
|
+
})
|
|
111
|
+
const legacy = signHs256Jwt({ sub: 'usr_2', type: 'human' }, secret, 60)
|
|
112
|
+
|
|
113
|
+
expect((await verify(modern))?.sub).toBe('usr_1')
|
|
114
|
+
expect((await verify(legacy))?.sub).toBe('usr_2')
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('rejects a legacy token once the secret is withdrawn', async () => {
|
|
118
|
+
const { signHs256Jwt } = await import('../jwt.js')
|
|
119
|
+
const key = await loadSigningKey((await freshKey()).pkcs8)
|
|
120
|
+
const legacy = signHs256Jwt({ sub: 'usr_2', type: 'human' }, secret, 60)
|
|
121
|
+
|
|
122
|
+
expect(await verifier({ jwks: jwks(key), issuer: ISS, audience: AUD })(legacy)).toBeNull()
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
describe('hono middleware', () => {
|
|
127
|
+
const secret = 'test-secret-32-chars-long-enough!'
|
|
128
|
+
|
|
129
|
+
async function run(env: Record<string, string | undefined>, token?: string) {
|
|
130
|
+
const { requireAuth, resetAuthVerifier } = await import('../hono.js')
|
|
131
|
+
for (const [k, v] of Object.entries(env)) {
|
|
132
|
+
if (v === undefined) delete process.env[k]
|
|
133
|
+
else process.env[k] = v
|
|
134
|
+
}
|
|
135
|
+
resetAuthVerifier()
|
|
136
|
+
|
|
137
|
+
const { Hono } = await import('hono')
|
|
138
|
+
const app = new Hono()
|
|
139
|
+
app.use('*', requireAuth)
|
|
140
|
+
app.get('/', (c) => c.json(c.get('auth')))
|
|
141
|
+
return app.request('/', token ? { headers: { Authorization: `Bearer ${token}` } } : {})
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
it('answers 500, not 401, when nothing is configured', async () => {
|
|
145
|
+
const res = await run({ AUTH_JWKS_URI: undefined, JWT_SECRET: undefined }, 'whatever')
|
|
146
|
+
expect(res.status).toBe(500)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('rejects a missing header', async () => {
|
|
150
|
+
expect((await run({ JWT_SECRET: secret })).status).toBe(401)
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('accepts a legacy HS256 token and classifies the token type', async () => {
|
|
154
|
+
const { signHs256Jwt } = await import('../jwt.js')
|
|
155
|
+
const res = await run(
|
|
156
|
+
{ AUTH_JWKS_URI: undefined, JWT_SECRET: secret },
|
|
157
|
+
signHs256Jwt({ sub: 'usr_1', type: 'service' }, secret, 60),
|
|
158
|
+
)
|
|
159
|
+
expect(res.status).toBe(200)
|
|
160
|
+
expect(await res.json()).toMatchObject({ userId: 'usr_1', tokenType: 'service' })
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('treats an unknown token type as a plain user token', async () => {
|
|
164
|
+
const { signHs256Jwt } = await import('../jwt.js')
|
|
165
|
+
const res = await run(
|
|
166
|
+
{ AUTH_JWKS_URI: undefined, JWT_SECRET: secret },
|
|
167
|
+
signHs256Jwt({ sub: 'usr_1', type: 'something-new' }, secret, 60),
|
|
168
|
+
)
|
|
169
|
+
expect(await res.json()).toMatchObject({ tokenType: 'user' })
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('rejects a token signed with a different secret', async () => {
|
|
173
|
+
const { signHs256Jwt } = await import('../jwt.js')
|
|
174
|
+
const res = await run(
|
|
175
|
+
{ AUTH_JWKS_URI: undefined, JWT_SECRET: secret },
|
|
176
|
+
signHs256Jwt({ sub: 'usr_1', type: 'human' }, 'a-completely-different-secret!!', 60),
|
|
177
|
+
)
|
|
178
|
+
expect(res.status).toBe(401)
|
|
179
|
+
})
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
describe('remote JWKS — the production path', () => {
|
|
183
|
+
it('fetches the key set over HTTP and verifies a token against it', async () => {
|
|
184
|
+
const { createServer } = await import('node:http')
|
|
185
|
+
const { exportPKCS8, generateKeyPair } = await import('jose')
|
|
186
|
+
|
|
187
|
+
const { privateKey } = await generateKeyPair('ES256', { extractable: true })
|
|
188
|
+
const key = await loadSigningKey(await exportPKCS8(privateKey))
|
|
189
|
+
|
|
190
|
+
let served = 0
|
|
191
|
+
const server = createServer((_req, res) => {
|
|
192
|
+
served += 1
|
|
193
|
+
res.writeHead(200, { 'content-type': 'application/json' })
|
|
194
|
+
res.end(JSON.stringify(jwks(key)))
|
|
195
|
+
})
|
|
196
|
+
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
|
|
197
|
+
const { port } = server.address() as { port: number }
|
|
198
|
+
|
|
199
|
+
try {
|
|
200
|
+
const verify = verifier({
|
|
201
|
+
jwksUri: `http://127.0.0.1:${port}/.well-known/jwks.json`,
|
|
202
|
+
issuer: ISS,
|
|
203
|
+
audience: AUD,
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
const token = await mint(key, { sub: 'usr_1', type: 'human' }, {
|
|
207
|
+
issuer: ISS, audience: AUD, expiresInSeconds: 60,
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
expect((await verify(token))?.sub).toBe('usr_1')
|
|
211
|
+
// Second call must come from cache: a fetch per request would put the
|
|
212
|
+
// auth service on the critical path of every request in the realm.
|
|
213
|
+
expect((await verify(token))?.sub).toBe('usr_1')
|
|
214
|
+
expect(served).toBe(1)
|
|
215
|
+
} finally {
|
|
216
|
+
await new Promise<void>((resolve) => server.close(() => resolve()))
|
|
217
|
+
}
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
it('returns null instead of throwing when the key set is unreachable', async () => {
|
|
221
|
+
const key = await loadSigningKey((await freshKey()).pkcs8)
|
|
222
|
+
const token = await mint(key, { sub: 'usr_1', type: 'human' }, {
|
|
223
|
+
issuer: ISS, audience: AUD, expiresInSeconds: 60,
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
let seen: unknown
|
|
227
|
+
// Port 1 is reserved and never listening.
|
|
228
|
+
const verify = verifier({
|
|
229
|
+
jwksUri: 'http://127.0.0.1:1/jwks.json',
|
|
230
|
+
issuer: ISS,
|
|
231
|
+
audience: AUD,
|
|
232
|
+
onError: (err) => { seen = err },
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
expect(await verify(token)).toBeNull()
|
|
236
|
+
expect(seen).toBeTruthy()
|
|
237
|
+
})
|
|
238
|
+
})
|
package/src/cli-auth.ts
CHANGED
|
@@ -20,7 +20,13 @@ export interface CliAuthResult {
|
|
|
20
20
|
export interface PollOptions {
|
|
21
21
|
/** ms between polls (default: 2000) */
|
|
22
22
|
intervalMs?: number;
|
|
23
|
-
/**
|
|
23
|
+
/**
|
|
24
|
+
* ms before giving up (default: 600_000 = 10 min).
|
|
25
|
+
*
|
|
26
|
+
* Prefer passing `start.expiresIn * 1000` so the CLI stops at the same moment
|
|
27
|
+
* the server drops the state; a shorter timeout reports a client-side
|
|
28
|
+
* giving-up while the link is in fact still good.
|
|
29
|
+
*/
|
|
24
30
|
timeoutMs?: number;
|
|
25
31
|
}
|
|
26
32
|
|
|
@@ -64,7 +70,7 @@ export async function pollCliAuth(
|
|
|
64
70
|
opts: PollOptions = {},
|
|
65
71
|
): Promise<CliAuthResult> {
|
|
66
72
|
const intervalMs = opts.intervalMs ?? 2_000;
|
|
67
|
-
const timeoutMs = opts.timeoutMs ??
|
|
73
|
+
const timeoutMs = opts.timeoutMs ?? 600_000;
|
|
68
74
|
const base = apiBase.replace(/\/+$/, '');
|
|
69
75
|
const deadline = Date.now() + timeoutMs;
|
|
70
76
|
|
|
@@ -86,14 +92,17 @@ export async function pollCliAuth(
|
|
|
86
92
|
return { token: data.token };
|
|
87
93
|
}
|
|
88
94
|
|
|
95
|
+
// The server answers 'expired' both for a state whose TTL ran out and for
|
|
96
|
+
// one it never had; from here the two are the same thing — nobody approved
|
|
97
|
+
// in time.
|
|
89
98
|
if (data.status === 'expired') {
|
|
90
|
-
throw new Error('
|
|
99
|
+
throw new Error('the sign-in was not approved in time — run login again');
|
|
91
100
|
}
|
|
92
101
|
|
|
93
102
|
// status === "pending" → continue polling
|
|
94
103
|
}
|
|
95
104
|
|
|
96
|
-
throw new Error('
|
|
105
|
+
throw new Error('no approval received in time — run login again');
|
|
97
106
|
}
|
|
98
107
|
|
|
99
108
|
// ─── helpers ────────────────────────────────────────────────────────────────
|