nixamp 0.4.0 → 0.5.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 +55 -6
- package/dist/accounts.d.ts +40 -0
- package/dist/accounts.js +95 -2
- package/dist/attach.d.ts +15 -0
- package/dist/attach.js +174 -0
- package/dist/device.d.ts +67 -0
- package/dist/device.js +157 -0
- package/dist/main.d.ts +12 -0
- package/dist/main.js +168 -7
- package/dist/oauth.d.ts +108 -0
- package/dist/oauth.js +302 -0
- package/dist/server.d.ts +8 -0
- package/dist/server.js +242 -2
- package/dist/session.d.ts +73 -2
- package/dist/session.js +308 -9
- package/dist/tokens.d.ts +63 -0
- package/dist/tokens.js +196 -0
- package/package.json +1 -1
- package/src/accounts.ts +103 -2
- package/src/attach.ts +191 -0
- package/src/device.ts +194 -0
- package/src/main.ts +174 -4
- package/src/oauth.ts +391 -0
- package/src/server.ts +278 -2
- package/src/session.ts +351 -9
- package/src/tokens.ts +247 -0
- package/web/dist/assets/index-pztl5rKf.js +1 -0
- package/web/dist/index.html +2 -1
- package/web/dist/sw.js +2 -2
- package/web/dist/assets/index-qRguFskX.js +0 -1
package/src/oauth.ts
ADDED
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Signing in with somebody else's account.
|
|
3
|
+
*
|
|
4
|
+
* Two providers, GitHub and Google, both plain OAuth 2.0 authorization code
|
|
5
|
+
* with a client secret held on the server. Neither is configured unless its
|
|
6
|
+
* two environment variables are set, and what is configured is advertised at
|
|
7
|
+
* /api/v1/auth/providers so the CLI can offer exactly the choices that will
|
|
8
|
+
* work rather than a menu of things that 404.
|
|
9
|
+
*
|
|
10
|
+
* The callback lives at /api/v1/<provider>/oauth/callback, which is the shape
|
|
11
|
+
* the rest of the fleet registers with providers: the site's API namespace,
|
|
12
|
+
* the version, then provider, function, endpoint.
|
|
13
|
+
*
|
|
14
|
+
* The one rule worth stating out loud: an address links to an account only if
|
|
15
|
+
* the provider says it verified it. GitHub and Google both report that per
|
|
16
|
+
* address, and both are asked. Without that check, anyone who can add an
|
|
17
|
+
* unverified address at a provider could claim somebody else's nixamp account.
|
|
18
|
+
*/
|
|
19
|
+
import { randomBytes } from "node:crypto";
|
|
20
|
+
import type { Account } from "./accounts.ts";
|
|
21
|
+
import type { DeviceGrants } from "./device.ts";
|
|
22
|
+
import type { Queryable } from "./follows.ts";
|
|
23
|
+
|
|
24
|
+
export interface Provider {
|
|
25
|
+
/** As it appears in a URL and in `nixamp login --with <id>`. */
|
|
26
|
+
id: string;
|
|
27
|
+
/** As it appears to a person. */
|
|
28
|
+
name: string;
|
|
29
|
+
clientId: string;
|
|
30
|
+
clientSecret: string;
|
|
31
|
+
authorizeUrl: string;
|
|
32
|
+
tokenUrl: string;
|
|
33
|
+
scope: string;
|
|
34
|
+
/** Turn the provider's access token into an address it stands behind. */
|
|
35
|
+
identify(accessToken: string, send: typeof fetch): Promise<Identity | null>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Who the provider says this is. `subject` is stable when an address is not. */
|
|
39
|
+
export interface Identity {
|
|
40
|
+
provider: string;
|
|
41
|
+
subject: string;
|
|
42
|
+
email: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const AGENT = { "user-agent": "nixamp" };
|
|
46
|
+
|
|
47
|
+
async function readJson(answer: Response): Promise<Record<string, unknown>> {
|
|
48
|
+
if (!answer.ok) return {};
|
|
49
|
+
return (await answer.json().catch(() => ({}))) as Record<string, unknown>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function githubProvider(clientId: string, clientSecret: string): Provider {
|
|
53
|
+
return {
|
|
54
|
+
id: "github",
|
|
55
|
+
name: "GitHub",
|
|
56
|
+
clientId,
|
|
57
|
+
clientSecret,
|
|
58
|
+
authorizeUrl: "https://github.com/login/oauth/authorize",
|
|
59
|
+
tokenUrl: "https://github.com/login/oauth/access_token",
|
|
60
|
+
// The address is the part nixamp needs, and GitHub keeps it behind its own
|
|
61
|
+
// scope even when it is public on a profile.
|
|
62
|
+
scope: "read:user user:email",
|
|
63
|
+
async identify(accessToken, send) {
|
|
64
|
+
const headers = { authorization: `Bearer ${accessToken}`, accept: "application/vnd.github+json", ...AGENT };
|
|
65
|
+
const who = await readJson(await send("https://api.github.com/user", { headers }));
|
|
66
|
+
const subject = who["id"] === undefined ? "" : String(who["id"]);
|
|
67
|
+
if (!subject) return null;
|
|
68
|
+
|
|
69
|
+
// /user carries an address only if the account made it public, and even
|
|
70
|
+
// then it may be an unverified one, so the addresses endpoint is asked.
|
|
71
|
+
const answer = await send("https://api.github.com/user/emails", { headers });
|
|
72
|
+
const list = answer.ok ? ((await answer.json().catch(() => [])) as Record<string, unknown>[]) : [];
|
|
73
|
+
const usable = Array.isArray(list) ? list.filter((row) => row["verified"] === true) : [];
|
|
74
|
+
const chosen = usable.find((row) => row["primary"] === true) ?? usable[0];
|
|
75
|
+
const email = typeof chosen?.["email"] === "string" ? chosen["email"] : "";
|
|
76
|
+
if (!email) return null;
|
|
77
|
+
return { provider: "github", subject, email: email.toLowerCase() };
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function googleProvider(clientId: string, clientSecret: string): Provider {
|
|
83
|
+
return {
|
|
84
|
+
id: "google",
|
|
85
|
+
name: "Google",
|
|
86
|
+
clientId,
|
|
87
|
+
clientSecret,
|
|
88
|
+
authorizeUrl: "https://accounts.google.com/o/oauth2/v2/auth",
|
|
89
|
+
tokenUrl: "https://oauth2.googleapis.com/token",
|
|
90
|
+
scope: "openid email",
|
|
91
|
+
async identify(accessToken, send) {
|
|
92
|
+
const who = await readJson(
|
|
93
|
+
await send("https://openidconnect.googleapis.com/v1/userinfo", {
|
|
94
|
+
headers: { authorization: `Bearer ${accessToken}`, ...AGENT },
|
|
95
|
+
}),
|
|
96
|
+
);
|
|
97
|
+
const subject = typeof who["sub"] === "string" ? who["sub"] : "";
|
|
98
|
+
const email = typeof who["email"] === "string" ? who["email"] : "";
|
|
99
|
+
// Google reports this as a boolean or as the string "true" depending on
|
|
100
|
+
// which endpoint answered, and an unverified address is not a claim.
|
|
101
|
+
const verified = who["email_verified"] === true || who["email_verified"] === "true";
|
|
102
|
+
if (!subject || !email || !verified) return null;
|
|
103
|
+
return { provider: "google", subject, email: email.toLowerCase() };
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Whichever providers this deployment has been given both halves of. */
|
|
109
|
+
export function providersFrom(env: Record<string, string | undefined>): Provider[] {
|
|
110
|
+
const found: Provider[] = [];
|
|
111
|
+
if (env["GITHUB_CLIENT_ID"] && env["GITHUB_CLIENT_SECRET"]) {
|
|
112
|
+
found.push(githubProvider(env["GITHUB_CLIENT_ID"], env["GITHUB_CLIENT_SECRET"]));
|
|
113
|
+
}
|
|
114
|
+
if (env["GOOGLE_CLIENT_ID"] && env["GOOGLE_CLIENT_SECRET"]) {
|
|
115
|
+
found.push(googleProvider(env["GOOGLE_CLIENT_ID"], env["GOOGLE_CLIENT_SECRET"]));
|
|
116
|
+
}
|
|
117
|
+
return found;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function redirectUri(site: string, provider: Provider): string {
|
|
121
|
+
return `${site.replace(/\/+$/, "")}/api/v1/${provider.id}/oauth/callback`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Where to send the browser. `state` is the only thing standing between this and CSRF. */
|
|
125
|
+
export function authorizeUrl(provider: Provider, site: string, state: string): string {
|
|
126
|
+
const url = new URL(provider.authorizeUrl);
|
|
127
|
+
url.searchParams.set("client_id", provider.clientId);
|
|
128
|
+
url.searchParams.set("redirect_uri", redirectUri(site, provider));
|
|
129
|
+
url.searchParams.set("response_type", "code");
|
|
130
|
+
url.searchParams.set("scope", provider.scope);
|
|
131
|
+
url.searchParams.set("state", state);
|
|
132
|
+
return url.toString();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Swap the code for an access token. Empty string means the provider refused. */
|
|
136
|
+
export async function exchangeCode(
|
|
137
|
+
provider: Provider,
|
|
138
|
+
code: string,
|
|
139
|
+
site: string,
|
|
140
|
+
send: typeof fetch = fetch,
|
|
141
|
+
): Promise<string> {
|
|
142
|
+
const answer = await send(provider.tokenUrl, {
|
|
143
|
+
method: "POST",
|
|
144
|
+
headers: {
|
|
145
|
+
// GitHub answers form-encoded unless asked for JSON, which is the trap
|
|
146
|
+
// that makes a working exchange look like an empty token.
|
|
147
|
+
accept: "application/json",
|
|
148
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
149
|
+
...AGENT,
|
|
150
|
+
},
|
|
151
|
+
body: new URLSearchParams({
|
|
152
|
+
client_id: provider.clientId,
|
|
153
|
+
client_secret: provider.clientSecret,
|
|
154
|
+
code,
|
|
155
|
+
redirect_uri: redirectUri(site, provider),
|
|
156
|
+
grant_type: "authorization_code",
|
|
157
|
+
}).toString(),
|
|
158
|
+
});
|
|
159
|
+
const body = await readJson(answer);
|
|
160
|
+
return typeof body["access_token"] === "string" ? body["access_token"] : "";
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// --- linking an identity to an account --------------------------------------
|
|
164
|
+
|
|
165
|
+
const TABLE = "nixamp_identities";
|
|
166
|
+
|
|
167
|
+
const SCHEMA = `
|
|
168
|
+
CREATE TABLE IF NOT EXISTS ${TABLE} (
|
|
169
|
+
provider TEXT NOT NULL,
|
|
170
|
+
subject TEXT NOT NULL,
|
|
171
|
+
user_id TEXT NOT NULL,
|
|
172
|
+
email TEXT NOT NULL DEFAULT '',
|
|
173
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
174
|
+
PRIMARY KEY (provider, subject)
|
|
175
|
+
);
|
|
176
|
+
`;
|
|
177
|
+
|
|
178
|
+
/** The slice of the auth module's storage adapter that identities need. */
|
|
179
|
+
export interface Users {
|
|
180
|
+
getUserByEmail(email: string): Promise<{ id?: string; email?: string } | null | undefined>;
|
|
181
|
+
createUser(user: {
|
|
182
|
+
email: string;
|
|
183
|
+
password: string | null;
|
|
184
|
+
emailVerified: boolean;
|
|
185
|
+
profile?: Record<string, unknown>;
|
|
186
|
+
}): Promise<{ id?: string; email?: string }>;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The account behind a provider identity, creating one the first time.
|
|
191
|
+
*
|
|
192
|
+
* The account row is made with no password at all rather than a random one
|
|
193
|
+
* nobody knows. A null password is a fact -- this account signs in with GitHub
|
|
194
|
+
* -- where a random password is a credential sitting in a database waiting to
|
|
195
|
+
* be found.
|
|
196
|
+
*/
|
|
197
|
+
export class Identities {
|
|
198
|
+
private ready: Promise<void> | null = null;
|
|
199
|
+
|
|
200
|
+
constructor(
|
|
201
|
+
private readonly db: Queryable,
|
|
202
|
+
private readonly users: Users,
|
|
203
|
+
) {}
|
|
204
|
+
|
|
205
|
+
private async ensure(): Promise<void> {
|
|
206
|
+
this.ready ??= this.db.query(SCHEMA).then(() => undefined);
|
|
207
|
+
await this.ready;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async resolve(identity: Identity): Promise<Account | null> {
|
|
211
|
+
if (!identity.subject || !identity.email) return null;
|
|
212
|
+
await this.ensure();
|
|
213
|
+
|
|
214
|
+
// Already linked. The subject is what is matched, not the address: people
|
|
215
|
+
// change the address on a GitHub account and stay the same person.
|
|
216
|
+
const { rows } = await this.db.query(`SELECT user_id, email FROM ${TABLE} WHERE provider = $1 AND subject = $2`, [
|
|
217
|
+
identity.provider,
|
|
218
|
+
identity.subject,
|
|
219
|
+
]);
|
|
220
|
+
const linked = rows[0];
|
|
221
|
+
if (linked) {
|
|
222
|
+
const id = String(linked["user_id"] ?? "");
|
|
223
|
+
if (String(linked["email"] ?? "") !== identity.email) {
|
|
224
|
+
await this.db.query(`UPDATE ${TABLE} SET email = $3 WHERE provider = $1 AND subject = $2`, [
|
|
225
|
+
identity.provider,
|
|
226
|
+
identity.subject,
|
|
227
|
+
identity.email,
|
|
228
|
+
]);
|
|
229
|
+
}
|
|
230
|
+
return { id, email: identity.email };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Not linked, but the address may already have an account -- somebody who
|
|
234
|
+
// signed up with a password and is now signing in with GitHub. Linking on
|
|
235
|
+
// a verified address is what makes those the same account instead of two.
|
|
236
|
+
const existing = await this.users.getUserByEmail(identity.email);
|
|
237
|
+
const account = existing?.id
|
|
238
|
+
? { id: existing.id, email: existing.email ?? identity.email }
|
|
239
|
+
: await (async () => {
|
|
240
|
+
const made = await this.users.createUser({
|
|
241
|
+
email: identity.email,
|
|
242
|
+
password: null,
|
|
243
|
+
// The provider verified it, which is the whole reason this is
|
|
244
|
+
// allowed to become an account without an email being sent.
|
|
245
|
+
emailVerified: true,
|
|
246
|
+
profile: { signedUpWith: identity.provider },
|
|
247
|
+
});
|
|
248
|
+
return { id: String(made.id ?? ""), email: made.email ?? identity.email };
|
|
249
|
+
})();
|
|
250
|
+
|
|
251
|
+
if (!account.id) return null;
|
|
252
|
+
await this.db.query(
|
|
253
|
+
`INSERT INTO ${TABLE} (provider, subject, user_id, email) VALUES ($1, $2, $3, $4)
|
|
254
|
+
ON CONFLICT (provider, subject) DO UPDATE SET email = EXCLUDED.email`,
|
|
255
|
+
[identity.provider, identity.subject, account.id, identity.email],
|
|
256
|
+
);
|
|
257
|
+
return account;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// --- the sign-in surface ----------------------------------------------------
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* What a round trip to a provider is for.
|
|
265
|
+
*
|
|
266
|
+
* A browser sent to GitHub comes back with a code and a state, and nothing
|
|
267
|
+
* else: whatever the request knew has to be remembered here in the meantime.
|
|
268
|
+
* The state is unguessable and single-use, which is what makes a callback
|
|
269
|
+
* somebody else caused useless.
|
|
270
|
+
*/
|
|
271
|
+
export interface Pending {
|
|
272
|
+
provider: string;
|
|
273
|
+
/** Set when this round trip is approving a terminal rather than a browser. */
|
|
274
|
+
userCode: string;
|
|
275
|
+
createdAt: number;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** A state is only ever open for the length of one sign-in. */
|
|
279
|
+
export const STATE_TTL_MS = 600_000;
|
|
280
|
+
|
|
281
|
+
export class SignIn {
|
|
282
|
+
private readonly states = new Map<string, Pending>();
|
|
283
|
+
|
|
284
|
+
constructor(
|
|
285
|
+
readonly providers: Provider[],
|
|
286
|
+
readonly device: DeviceGrants,
|
|
287
|
+
readonly site: string,
|
|
288
|
+
private readonly now: () => number = Date.now,
|
|
289
|
+
) {}
|
|
290
|
+
|
|
291
|
+
/** What /api/v1/auth/providers says, and what the CLI menu is built from. */
|
|
292
|
+
get offered(): { id: string; name: string }[] {
|
|
293
|
+
return this.providers.map((provider) => ({ id: provider.id, name: provider.name }));
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
provider(id: unknown): Provider | null {
|
|
297
|
+
return this.providers.find((candidate) => candidate.id === id) ?? null;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Start a round trip, and answer the URL the browser should go to. */
|
|
301
|
+
begin(provider: Provider, userCode = ""): string {
|
|
302
|
+
this.sweep();
|
|
303
|
+
const state = randomBytes(24).toString("base64url");
|
|
304
|
+
this.states.set(state, { provider: provider.id, userCode, createdAt: this.now() });
|
|
305
|
+
return authorizeUrl(provider, this.site, state);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Redeem a state exactly once, so a replayed callback finds nothing. */
|
|
309
|
+
claim(state: unknown): Pending | null {
|
|
310
|
+
if (typeof state !== "string" || state === "") return null;
|
|
311
|
+
const pending = this.states.get(state);
|
|
312
|
+
if (!pending) return null;
|
|
313
|
+
this.states.delete(state);
|
|
314
|
+
return this.now() - pending.createdAt > STATE_TTL_MS ? null : pending;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
private sweep(): void {
|
|
318
|
+
const at = this.now();
|
|
319
|
+
for (const [state, pending] of this.states) {
|
|
320
|
+
if (at - pending.createdAt > STATE_TTL_MS) this.states.delete(state);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function escapeHtml(value: string): string {
|
|
326
|
+
return value.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const PAGE_STYLE = `
|
|
330
|
+
:root { color-scheme: dark }
|
|
331
|
+
body { background:#000; color:#00e676; font:16px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;
|
|
332
|
+
margin:0; min-height:100vh; display:grid; place-items:center; padding:2rem }
|
|
333
|
+
main { width:min(30rem,100%) }
|
|
334
|
+
h1 { font-size:1.1rem; letter-spacing:.2em; text-transform:uppercase; color:#9ad }
|
|
335
|
+
input { font:inherit; background:#111; color:#00e676; border:1px solid #2a2a2a; padding:.6rem .8rem;
|
|
336
|
+
width:100%; box-sizing:border-box; letter-spacing:.25em; text-transform:uppercase }
|
|
337
|
+
button { font:inherit; background:#111; color:#00e676; border:1px solid #2a2a2a; padding:.6rem 1rem;
|
|
338
|
+
cursor:pointer; width:100%; margin-top:.5rem; text-align:left }
|
|
339
|
+
button:hover { border-color:#00e676 }
|
|
340
|
+
p { color:#9a9a9a } code { color:#00e676 }
|
|
341
|
+
`;
|
|
342
|
+
|
|
343
|
+
function page(title: string, body: string): string {
|
|
344
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8">
|
|
345
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
346
|
+
<title>${escapeHtml(title)} - nixamp</title><style>${PAGE_STYLE}</style></head>
|
|
347
|
+
<body><main>${body}</main></body></html>`;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* The page the terminal sends somebody to.
|
|
352
|
+
*
|
|
353
|
+
* It is served by the API rather than the app because it has to work before
|
|
354
|
+
* there is a session and without the web build being present -- a nixamp
|
|
355
|
+
* deployed with no web assets can still sign a terminal in.
|
|
356
|
+
*/
|
|
357
|
+
export function devicePage(signIn: SignIn, code: string, signedInAs: string): string {
|
|
358
|
+
const value = escapeHtml(code);
|
|
359
|
+
const choices = signIn.providers
|
|
360
|
+
.map(
|
|
361
|
+
(provider) =>
|
|
362
|
+
`<button type="submit" name="with" value="${escapeHtml(provider.id)}">Continue with ${escapeHtml(provider.name)}</button>`,
|
|
363
|
+
)
|
|
364
|
+
.join("");
|
|
365
|
+
const approve = signedInAs
|
|
366
|
+
? `<button type="submit" name="with" value="">Approve as ${escapeHtml(signedInAs)}</button>`
|
|
367
|
+
: "";
|
|
368
|
+
return page(
|
|
369
|
+
"Connect a terminal",
|
|
370
|
+
`<h1>Connect a terminal</h1>
|
|
371
|
+
<p>Your terminal is showing a code. Type it here, then choose how to sign in.</p>
|
|
372
|
+
<form method="POST" action="/api/v1/auth/device">
|
|
373
|
+
<input name="code" value="${value}" placeholder="XXXX-XXXX" autocomplete="off"
|
|
374
|
+
autocapitalize="characters" spellcheck="false" required>
|
|
375
|
+
${approve}${choices}
|
|
376
|
+
</form>
|
|
377
|
+
${signIn.providers.length === 0 && !signedInAs ? "<p>Sign in on this site first, then come back to this page.</p>" : ""}`,
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
export function deviceDonePage(email: string): string {
|
|
382
|
+
return page(
|
|
383
|
+
"Terminal connected",
|
|
384
|
+
`<h1>Terminal connected</h1>
|
|
385
|
+
<p>Signed in as <code>${escapeHtml(email)}</code>. Go back to your terminal; you can close this.</p>`,
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
export function signInFailedPage(why: string): string {
|
|
390
|
+
return page("Sign-in failed", `<h1>Sign-in failed</h1><p>${escapeHtml(why)}</p>`);
|
|
391
|
+
}
|