@viktorbarzin/auth 0.0.0-stage → 1.0.0
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 +63 -2
- package/dist/app-auth.d.ts +160 -0
- package/dist/app-auth.js +588 -0
- package/dist/authentik.d.ts +44 -0
- package/dist/authentik.js +115 -0
- package/dist/config.d.ts +120 -0
- package/dist/config.js +15 -0
- package/dist/csrf.d.ts +7 -0
- package/dist/csrf.js +45 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +7 -0
- package/dist/membership.d.ts +59 -0
- package/dist/membership.js +118 -0
- package/dist/metrics.d.ts +14 -0
- package/dist/metrics.js +19 -0
- package/dist/return-path.d.ts +16 -0
- package/dist/return-path.js +40 -0
- package/dist/schema.d.ts +2367 -0
- package/dist/schema.js +110 -0
- package/dist/testing/built-server.d.ts +50 -0
- package/dist/testing/built-server.js +157 -0
- package/dist/testing/index.d.ts +7 -0
- package/dist/testing/index.js +7 -0
- package/dist/testing/mock-authentik.d.ts +70 -0
- package/dist/testing/mock-authentik.js +299 -0
- package/package.json +66 -5
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A mock Authentik for tests: an OIDC provider (discovery, authorize, token,
|
|
3
|
+
* userinfo, JWKS) and the few /api/v3 calls the app makes (user lookup, group
|
|
4
|
+
* lookup, add_user). Runs on 127.0.0.1 with a random port.
|
|
5
|
+
*
|
|
6
|
+
* The provider behaves like Authentik where the app depends on it:
|
|
7
|
+
* - `sub` is the user's `uid` (sub_mode hashed_user_id), id_tokens and access
|
|
8
|
+
* tokens are RS256 JWTs with `aud` = client_id;
|
|
9
|
+
* - authorization codes require PKCE S256 and the client secret;
|
|
10
|
+
* - a refresh token is issued only when `offline_access` was requested;
|
|
11
|
+
* - the browser's provider session may be the anonymous `guest`, which is
|
|
12
|
+
* what a silent authorize returns until `prompt=login` makes the user log in.
|
|
13
|
+
*
|
|
14
|
+
* Nothing here is safe outside tests: tokens are fixed strings.
|
|
15
|
+
*/
|
|
16
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
17
|
+
import { exportJWK, generateKeyPair, SignJWT, } from "jose";
|
|
18
|
+
function b64url(buffer) {
|
|
19
|
+
return buffer.toString("base64url");
|
|
20
|
+
}
|
|
21
|
+
export async function startMockAuthentik(options) {
|
|
22
|
+
const { privateKey, publicKey } = await generateKeyPair("RS256", {
|
|
23
|
+
extractable: true,
|
|
24
|
+
});
|
|
25
|
+
const jwk = {
|
|
26
|
+
...(await exportJWK(publicKey)),
|
|
27
|
+
kid: "mock-rs256",
|
|
28
|
+
alg: "RS256",
|
|
29
|
+
use: "sig",
|
|
30
|
+
};
|
|
31
|
+
const codes = new Map();
|
|
32
|
+
const accessTokens = new Map();
|
|
33
|
+
let pkSeq = 100;
|
|
34
|
+
const server = Bun.serve({
|
|
35
|
+
hostname: "127.0.0.1",
|
|
36
|
+
port: 0,
|
|
37
|
+
fetch: (request) => handle(request),
|
|
38
|
+
});
|
|
39
|
+
const origin = `http://127.0.0.1:${server.port}`;
|
|
40
|
+
const slug = options.slug ?? options.clientId;
|
|
41
|
+
const appPath = `/application/o/${slug}/`;
|
|
42
|
+
const issuer = `${origin}${appPath}`;
|
|
43
|
+
const mock = {
|
|
44
|
+
issuer,
|
|
45
|
+
discoveryUrl: `${issuer}.well-known/openid-configuration`,
|
|
46
|
+
apiUrl: origin,
|
|
47
|
+
clientId: options.clientId,
|
|
48
|
+
clientSecret: options.clientSecret ?? `${options.clientId}-secret`,
|
|
49
|
+
readerToken: "reader-token",
|
|
50
|
+
approverToken: "approver-token",
|
|
51
|
+
usersGroupPk: "0b6c8f0e-4c1e-4a43-9d39-6f0c1d2a7e11",
|
|
52
|
+
usersGroup: options.usersGroup ?? "App Users",
|
|
53
|
+
users: new Map(),
|
|
54
|
+
session: null,
|
|
55
|
+
loginAs: null,
|
|
56
|
+
authorizations: [],
|
|
57
|
+
tokenRequests: [],
|
|
58
|
+
apiCalls: [],
|
|
59
|
+
apiFailure: null,
|
|
60
|
+
addUser(partial) {
|
|
61
|
+
pkSeq += 1;
|
|
62
|
+
const user = {
|
|
63
|
+
pk: partial.pk ?? pkSeq,
|
|
64
|
+
uid: partial.uid ??
|
|
65
|
+
createHash("sha256").update(`${pkSeq}-mock`).digest("hex"),
|
|
66
|
+
username: partial.username,
|
|
67
|
+
email: partial.email === undefined ? partial.username : partial.email,
|
|
68
|
+
name: partial.name ?? partial.username,
|
|
69
|
+
isActive: partial.isActive ?? true,
|
|
70
|
+
groups: partial.groups ?? [],
|
|
71
|
+
};
|
|
72
|
+
mock.users.set(user.username, user);
|
|
73
|
+
return user;
|
|
74
|
+
},
|
|
75
|
+
async mintAccessToken(claims, opts = {}) {
|
|
76
|
+
const alg = opts.alg ?? "RS256";
|
|
77
|
+
if (alg !== "RS256") {
|
|
78
|
+
// A symmetric token signed with a key the verifier does not hold.
|
|
79
|
+
return new SignJWT(claims)
|
|
80
|
+
.setProtectedHeader({ alg })
|
|
81
|
+
.setIssuer(issuer)
|
|
82
|
+
.setAudience(opts.audience ?? mock.clientId)
|
|
83
|
+
.setIssuedAt()
|
|
84
|
+
.setExpirationTime(opts.expiresIn ?? "15m")
|
|
85
|
+
.sign(new TextEncoder().encode("x".repeat(64)));
|
|
86
|
+
}
|
|
87
|
+
return new SignJWT(claims)
|
|
88
|
+
.setProtectedHeader({ alg, kid: jwk.kid })
|
|
89
|
+
.setIssuer(issuer)
|
|
90
|
+
.setAudience(opts.audience ?? mock.clientId)
|
|
91
|
+
.setIssuedAt()
|
|
92
|
+
.setExpirationTime(opts.expiresIn ?? "15m")
|
|
93
|
+
.sign(privateKey);
|
|
94
|
+
},
|
|
95
|
+
async stop() {
|
|
96
|
+
await server.stop(true);
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
function claimsFor(user) {
|
|
100
|
+
return {
|
|
101
|
+
sub: user.uid,
|
|
102
|
+
preferred_username: user.username,
|
|
103
|
+
name: user.name,
|
|
104
|
+
...(user.email ? { email: user.email, email_verified: true } : {}),
|
|
105
|
+
groups: user.groups,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
async function handle(request) {
|
|
109
|
+
const url = new URL(request.url);
|
|
110
|
+
const path = url.pathname;
|
|
111
|
+
if (path === `${appPath}.well-known/openid-configuration`) {
|
|
112
|
+
return Response.json({
|
|
113
|
+
issuer,
|
|
114
|
+
authorization_endpoint: `${origin}/application/o/authorize/`,
|
|
115
|
+
token_endpoint: `${origin}/application/o/token/`,
|
|
116
|
+
userinfo_endpoint: `${origin}/application/o/userinfo/`,
|
|
117
|
+
end_session_endpoint: `${origin}${appPath}end-session/`,
|
|
118
|
+
jwks_uri: `${origin}${appPath}jwks/`,
|
|
119
|
+
response_types_supported: ["code"],
|
|
120
|
+
id_token_signing_alg_values_supported: ["RS256"],
|
|
121
|
+
code_challenge_methods_supported: ["S256", "plain"],
|
|
122
|
+
scopes_supported: ["openid", "profile", "email", "offline_access"],
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
if (path === `${appPath}jwks/`)
|
|
126
|
+
return Response.json({ keys: [jwk] });
|
|
127
|
+
if (path === "/application/o/authorize/")
|
|
128
|
+
return authorize(url);
|
|
129
|
+
if (path === "/application/o/token/" && request.method === "POST") {
|
|
130
|
+
return token(request);
|
|
131
|
+
}
|
|
132
|
+
if (path === "/application/o/userinfo/") {
|
|
133
|
+
const bearer = request.headers
|
|
134
|
+
.get("authorization")
|
|
135
|
+
?.replace(/^Bearer /, "");
|
|
136
|
+
const user = bearer ? accessTokens.get(bearer) : undefined;
|
|
137
|
+
if (!user)
|
|
138
|
+
return new Response("unauthorized", { status: 401 });
|
|
139
|
+
return Response.json(claimsFor(user));
|
|
140
|
+
}
|
|
141
|
+
if (path.startsWith("/api/v3/"))
|
|
142
|
+
return api(request, url);
|
|
143
|
+
return new Response("not found", { status: 404 });
|
|
144
|
+
}
|
|
145
|
+
function authorize(url) {
|
|
146
|
+
const params = url.searchParams;
|
|
147
|
+
const clientId = params.get("client_id") ?? "";
|
|
148
|
+
const redirectUri = params.get("redirect_uri") ?? "";
|
|
149
|
+
const prompt = params.get("prompt");
|
|
150
|
+
if (clientId !== mock.clientId)
|
|
151
|
+
return new Response("bad client", { status: 400 });
|
|
152
|
+
// A session at the provider answers silently; prompt=login (or no
|
|
153
|
+
// session) shows the login form, and `loginAs` logs in.
|
|
154
|
+
let username = mock.session;
|
|
155
|
+
if (prompt === "login" || username === null) {
|
|
156
|
+
username = mock.loginAs;
|
|
157
|
+
if (username)
|
|
158
|
+
mock.session = username;
|
|
159
|
+
}
|
|
160
|
+
mock.authorizations.push({
|
|
161
|
+
clientId,
|
|
162
|
+
scope: params.get("scope") ?? "",
|
|
163
|
+
prompt,
|
|
164
|
+
codeChallengeMethod: params.get("code_challenge_method"),
|
|
165
|
+
nonce: params.get("nonce"),
|
|
166
|
+
user: username ?? "",
|
|
167
|
+
});
|
|
168
|
+
const user = username ? mock.users.get(username) : undefined;
|
|
169
|
+
if (!user)
|
|
170
|
+
return new Response("login form", { status: 200 });
|
|
171
|
+
const code = randomUUID();
|
|
172
|
+
codes.set(code, {
|
|
173
|
+
clientId,
|
|
174
|
+
redirectUri,
|
|
175
|
+
challenge: params.get("code_challenge") ?? "",
|
|
176
|
+
nonce: params.get("nonce"),
|
|
177
|
+
scope: params.get("scope") ?? "",
|
|
178
|
+
user,
|
|
179
|
+
});
|
|
180
|
+
const back = new URL(redirectUri);
|
|
181
|
+
back.searchParams.set("code", code);
|
|
182
|
+
back.searchParams.set("state", params.get("state") ?? "");
|
|
183
|
+
back.searchParams.set("iss", issuer);
|
|
184
|
+
return new Response(null, {
|
|
185
|
+
status: 302,
|
|
186
|
+
headers: { location: back.toString() },
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
async function token(request) {
|
|
190
|
+
const form = new URLSearchParams(await request.text());
|
|
191
|
+
let clientId = form.get("client_id");
|
|
192
|
+
let clientSecret = form.get("client_secret");
|
|
193
|
+
const basic = request.headers.get("authorization");
|
|
194
|
+
if (basic?.startsWith("Basic ")) {
|
|
195
|
+
const [id, secret] = Buffer.from(basic.slice(6), "base64")
|
|
196
|
+
.toString()
|
|
197
|
+
.split(":");
|
|
198
|
+
clientId = decodeURIComponent(id ?? "");
|
|
199
|
+
clientSecret = decodeURIComponent(secret ?? "");
|
|
200
|
+
}
|
|
201
|
+
mock.tokenRequests.push({
|
|
202
|
+
grantType: form.get("grant_type") ?? "",
|
|
203
|
+
scope: form.get("scope") ?? "",
|
|
204
|
+
});
|
|
205
|
+
if (clientId !== mock.clientId || clientSecret !== mock.clientSecret) {
|
|
206
|
+
return Response.json({ error: "invalid_client" }, { status: 401 });
|
|
207
|
+
}
|
|
208
|
+
if (form.get("grant_type") !== "authorization_code") {
|
|
209
|
+
return Response.json({ error: "unsupported_grant_type" }, { status: 400 });
|
|
210
|
+
}
|
|
211
|
+
const issued = codes.get(form.get("code") ?? "");
|
|
212
|
+
codes.delete(form.get("code") ?? "");
|
|
213
|
+
if (!issued || issued.redirectUri !== form.get("redirect_uri")) {
|
|
214
|
+
return Response.json({ error: "invalid_grant" }, { status: 400 });
|
|
215
|
+
}
|
|
216
|
+
const verifier = form.get("code_verifier") ?? "";
|
|
217
|
+
const challenge = b64url(createHash("sha256").update(verifier).digest());
|
|
218
|
+
if (!issued.challenge || challenge !== issued.challenge) {
|
|
219
|
+
return Response.json({ error: "invalid_grant", error_description: "pkce" }, { status: 400 });
|
|
220
|
+
}
|
|
221
|
+
const claims = claimsFor(issued.user);
|
|
222
|
+
const idToken = await new SignJWT({
|
|
223
|
+
...claims,
|
|
224
|
+
...(issued.nonce ? { nonce: issued.nonce } : {}),
|
|
225
|
+
})
|
|
226
|
+
.setProtectedHeader({ alg: "RS256", kid: jwk.kid })
|
|
227
|
+
.setIssuer(issuer)
|
|
228
|
+
.setAudience(mock.clientId)
|
|
229
|
+
.setIssuedAt()
|
|
230
|
+
.setExpirationTime("15m")
|
|
231
|
+
.sign(privateKey);
|
|
232
|
+
const accessToken = await mock.mintAccessToken(claims);
|
|
233
|
+
accessTokens.set(accessToken, issued.user);
|
|
234
|
+
const scopes = issued.scope.split(" ");
|
|
235
|
+
return Response.json({
|
|
236
|
+
access_token: accessToken,
|
|
237
|
+
token_type: "Bearer",
|
|
238
|
+
expires_in: 900,
|
|
239
|
+
id_token: idToken,
|
|
240
|
+
scope: issued.scope,
|
|
241
|
+
...(scopes.includes("offline_access")
|
|
242
|
+
? { refresh_token: randomUUID() }
|
|
243
|
+
: {}),
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
async function api(request, url) {
|
|
247
|
+
const token = request.headers.get("authorization")?.replace(/^Bearer /, "") ?? null;
|
|
248
|
+
const path = url.pathname.slice("/api/v3".length);
|
|
249
|
+
mock.apiCalls.push({ method: request.method, path, token });
|
|
250
|
+
if (mock.apiFailure !== null)
|
|
251
|
+
return new Response("down", { status: mock.apiFailure });
|
|
252
|
+
const reader = token === mock.readerToken;
|
|
253
|
+
const approver = token === mock.approverToken;
|
|
254
|
+
if (path === "/core/users/" && request.method === "GET") {
|
|
255
|
+
if (!reader)
|
|
256
|
+
return new Response("forbidden", { status: 403 });
|
|
257
|
+
const username = url.searchParams.get("username");
|
|
258
|
+
const results = [...mock.users.values()]
|
|
259
|
+
.filter((user) => username === null || user.username === username)
|
|
260
|
+
.map((user) => ({
|
|
261
|
+
pk: user.pk,
|
|
262
|
+
uid: user.uid,
|
|
263
|
+
username: user.username,
|
|
264
|
+
is_active: user.isActive,
|
|
265
|
+
groups_obj: url.searchParams.get("include_groups")
|
|
266
|
+
? user.groups.map((name) => ({ name, pk: randomUUID() }))
|
|
267
|
+
: undefined,
|
|
268
|
+
}));
|
|
269
|
+
return Response.json({ pagination: { count: results.length }, results });
|
|
270
|
+
}
|
|
271
|
+
if (path === "/core/groups/" && request.method === "GET") {
|
|
272
|
+
if (!reader)
|
|
273
|
+
return new Response("forbidden", { status: 403 });
|
|
274
|
+
const name = url.searchParams.get("name");
|
|
275
|
+
const results = name === mock.usersGroup
|
|
276
|
+
? [{ pk: mock.usersGroupPk, name: mock.usersGroup }]
|
|
277
|
+
: [];
|
|
278
|
+
return Response.json({ pagination: { count: results.length }, results });
|
|
279
|
+
}
|
|
280
|
+
const add = /^\/core\/groups\/([^/]+)\/add_user\/$/.exec(path);
|
|
281
|
+
if (add && request.method === "POST") {
|
|
282
|
+
// The approver token may only add users to the users group.
|
|
283
|
+
if (!approver || add[1] !== mock.usersGroupPk) {
|
|
284
|
+
return new Response("forbidden", { status: 403 });
|
|
285
|
+
}
|
|
286
|
+
const body = (await request.json());
|
|
287
|
+
const user = [...mock.users.values()].find((u) => u.pk === body.pk);
|
|
288
|
+
if (!user)
|
|
289
|
+
return Response.json({ detail: "not found" }, { status: 400 });
|
|
290
|
+
if (!user.groups.includes(mock.usersGroup))
|
|
291
|
+
user.groups.push(mock.usersGroup);
|
|
292
|
+
return new Response(null, { status: 204 });
|
|
293
|
+
}
|
|
294
|
+
return new Response("not found", { status: 404 });
|
|
295
|
+
}
|
|
296
|
+
for (const user of options.users ?? [])
|
|
297
|
+
mock.addUser(user);
|
|
298
|
+
return mock;
|
|
299
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,67 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
"name": "@viktorbarzin/auth",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "App-side OIDC sign-in with Better Auth against Authentik: sessions, bearer tokens, membership re-checks, request and approve, CSRF origin checks.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"default": "./dist/index.js"
|
|
13
|
+
},
|
|
14
|
+
"./schema": {
|
|
15
|
+
"types": "./dist/schema.d.ts",
|
|
16
|
+
"default": "./dist/schema.js"
|
|
17
|
+
},
|
|
18
|
+
"./testing": {
|
|
19
|
+
"types": "./dist/testing/index.d.ts",
|
|
20
|
+
"default": "./dist/testing/index.js"
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"dist"
|
|
25
|
+
],
|
|
26
|
+
"sideEffects": false,
|
|
27
|
+
"engines": {
|
|
28
|
+
"bun": ">=1.4.3"
|
|
29
|
+
},
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "git+https://github.com/ViktorBarzin/app-kit.git",
|
|
33
|
+
"directory": "packages/auth"
|
|
34
|
+
},
|
|
35
|
+
"homepage": "https://github.com/ViktorBarzin/app-kit/tree/master/packages/auth#readme",
|
|
36
|
+
"publishConfig": {
|
|
37
|
+
"access": "public",
|
|
38
|
+
"provenance": true
|
|
39
|
+
},
|
|
40
|
+
"scripts": {
|
|
41
|
+
"build": "tsc -b",
|
|
42
|
+
"test": "bun test"
|
|
43
|
+
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"jose": "^6.2.12"
|
|
46
|
+
},
|
|
47
|
+
"peerDependencies": {
|
|
48
|
+
"@viktorbarzin/log": "^1.0.0",
|
|
49
|
+
"@viktorbarzin/runtime": "^1.0.0",
|
|
50
|
+
"@viktorbarzin/metrics": "^1.0.0",
|
|
51
|
+
"@viktorbarzin/oidc-token": "^1.0.0",
|
|
52
|
+
"better-auth": ">=1.7.7 <1.8.0",
|
|
53
|
+
"drizzle-orm": "^0.45.0",
|
|
54
|
+
"prom-client": "^15.1.0"
|
|
55
|
+
},
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"@viktorbarzin/log": "^1.0.0",
|
|
58
|
+
"@viktorbarzin/runtime": "^1.0.0",
|
|
59
|
+
"@viktorbarzin/metrics": "^1.0.0",
|
|
60
|
+
"@viktorbarzin/oidc-token": "^1.0.0",
|
|
61
|
+
"@viktorbarzin/pg": "^1.0.0",
|
|
62
|
+
"better-auth": "1.7.7",
|
|
63
|
+
"drizzle-orm": "^0.45.4",
|
|
64
|
+
"prom-client": "^15.1.3",
|
|
65
|
+
"drizzle-kit": "^0.31.9"
|
|
66
|
+
}
|
|
67
|
+
}
|