Repository navigation
Expand file tree
/
Copy pathauth.js
More file actions
104 lines (96 loc) · 4.15 KB
/
Copy pathauth.js
File metadata and controls
104 lines (96 loc) · 4.15 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
// Shared auth helpers used by all three services.
//
// - verifier(): JWKS-based JWT verification (jose) with iss/aud checks.
// - requireAuth(): express middleware factory enforcing a verified Bearer
// token, optionally requiring service_account tokens with
// a given scope.
// - MachineTokenClient: fetches + caches a client_credentials token from
// POST /oauth/token (form-encoded, client_secret_post).
import { createRemoteJWKSet, jwtVerify } from "jose";
try {
process.loadEnvFile(new URL("../.env", import.meta.url).pathname);
} catch {
// no .env yet (e.g. env passed via docker-compose) — that's fine
}
export const AUTHORIZER_URL = (process.env.AUTHORIZER_URL || "http://localhost:8080").replace(/\/$/, "");
export const AUTHORIZER_CLIENT_ID = process.env.AUTHORIZER_CLIENT_ID;
// Authorizer sets the token `iss` to the host the token request hit. When all
// parties reach Authorizer through the same URL this equals AUTHORIZER_URL;
// override EXPECTED_ISSUER if e.g. user tokens are minted via a different host
// (see README "issuer pitfall").
export const EXPECTED_ISSUER = process.env.EXPECTED_ISSUER || AUTHORIZER_URL;
// One remote JWKS per process; jose caches and re-fetches on unknown keys.
const JWKS = createRemoteJWKSet(new URL(`${AUTHORIZER_URL}/.well-known/jwks.json`));
// Verify signature + iss + aud. `aud` on every Authorizer-minted JWT (user or
// machine) is the instance's global client ID, NOT the per-service client_id.
export async function verifyToken(token) {
const { payload } = await jwtVerify(token, JWKS, {
issuer: EXPECTED_ISSUER,
audience: AUTHORIZER_CLIENT_ID,
});
return payload;
}
// Express middleware. opts:
// machine: true -> require a service_account (client_credentials) token
// scope: "x:y" -> require that scope. NOTE: Authorizer's `scope` claim is
// a JSON ARRAY of strings, not a space-delimited string.
export function requireAuth(opts = {}) {
return async (req, res, next) => {
const header = req.headers.authorization || "";
const [type, token] = header.split(" ");
if (type?.toLowerCase() !== "bearer" || !token) {
return res.status(401).json({ error: "missing bearer token" });
}
let payload;
try {
payload = await verifyToken(token);
} catch (err) {
return res.status(401).json({ error: "invalid token", detail: err.code || err.message });
}
if (opts.machine && payload.login_method !== "service_account") {
return res.status(403).json({ error: "machine (service_account) token required" });
}
if (opts.scope) {
const scopes = Array.isArray(payload.scope) ? payload.scope : String(payload.scope || "").split(" ");
if (!scopes.includes(opts.scope)) {
return res.status(403).json({ error: `insufficient scope: '${opts.scope}' required`, granted: scopes });
}
}
req.auth = payload;
next();
};
}
// client_credentials token fetcher with expiry-aware caching.
export class MachineTokenClient {
constructor({ clientId, clientSecret, scope }) {
this.clientId = clientId;
this.clientSecret = clientSecret;
this.scope = scope;
this.token = null;
this.expiresAt = 0;
}
async get() {
// Refresh 30s before expiry.
if (this.token && Date.now() < this.expiresAt - 30_000) return this.token;
const res = await fetch(`${AUTHORIZER_URL}/oauth/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "client_credentials",
client_id: this.clientId,
client_secret: this.clientSecret,
// Request only the scope this call path needs — even below the
// service's own ceiling (least privilege). Requesting anything
// outside allowed_scopes fails with invalid_scope.
scope: this.scope,
}),
});
const body = await res.json();
if (!res.ok) {
throw new Error(`token request failed: ${body.error}: ${body.error_description}`);
}
this.token = body.access_token;
this.expiresAt = Date.now() + body.expires_in * 1000;
return this.token;
}
}