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/dist/oauth.js ADDED
@@ -0,0 +1,302 @@
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
+ const AGENT = { "user-agent": "nixamp" };
21
+ async function readJson(answer) {
22
+ if (!answer.ok)
23
+ return {};
24
+ return (await answer.json().catch(() => ({})));
25
+ }
26
+ export function githubProvider(clientId, clientSecret) {
27
+ return {
28
+ id: "github",
29
+ name: "GitHub",
30
+ clientId,
31
+ clientSecret,
32
+ authorizeUrl: "https://github.com/login/oauth/authorize",
33
+ tokenUrl: "https://github.com/login/oauth/access_token",
34
+ // The address is the part nixamp needs, and GitHub keeps it behind its own
35
+ // scope even when it is public on a profile.
36
+ scope: "read:user user:email",
37
+ async identify(accessToken, send) {
38
+ const headers = { authorization: `Bearer ${accessToken}`, accept: "application/vnd.github+json", ...AGENT };
39
+ const who = await readJson(await send("https://api.github.com/user", { headers }));
40
+ const subject = who["id"] === undefined ? "" : String(who["id"]);
41
+ if (!subject)
42
+ return null;
43
+ // /user carries an address only if the account made it public, and even
44
+ // then it may be an unverified one, so the addresses endpoint is asked.
45
+ const answer = await send("https://api.github.com/user/emails", { headers });
46
+ const list = answer.ok ? (await answer.json().catch(() => [])) : [];
47
+ const usable = Array.isArray(list) ? list.filter((row) => row["verified"] === true) : [];
48
+ const chosen = usable.find((row) => row["primary"] === true) ?? usable[0];
49
+ const email = typeof chosen?.["email"] === "string" ? chosen["email"] : "";
50
+ if (!email)
51
+ return null;
52
+ return { provider: "github", subject, email: email.toLowerCase() };
53
+ },
54
+ };
55
+ }
56
+ export function googleProvider(clientId, clientSecret) {
57
+ return {
58
+ id: "google",
59
+ name: "Google",
60
+ clientId,
61
+ clientSecret,
62
+ authorizeUrl: "https://accounts.google.com/o/oauth2/v2/auth",
63
+ tokenUrl: "https://oauth2.googleapis.com/token",
64
+ scope: "openid email",
65
+ async identify(accessToken, send) {
66
+ const who = await readJson(await send("https://openidconnect.googleapis.com/v1/userinfo", {
67
+ headers: { authorization: `Bearer ${accessToken}`, ...AGENT },
68
+ }));
69
+ const subject = typeof who["sub"] === "string" ? who["sub"] : "";
70
+ const email = typeof who["email"] === "string" ? who["email"] : "";
71
+ // Google reports this as a boolean or as the string "true" depending on
72
+ // which endpoint answered, and an unverified address is not a claim.
73
+ const verified = who["email_verified"] === true || who["email_verified"] === "true";
74
+ if (!subject || !email || !verified)
75
+ return null;
76
+ return { provider: "google", subject, email: email.toLowerCase() };
77
+ },
78
+ };
79
+ }
80
+ /** Whichever providers this deployment has been given both halves of. */
81
+ export function providersFrom(env) {
82
+ const found = [];
83
+ if (env["GITHUB_CLIENT_ID"] && env["GITHUB_CLIENT_SECRET"]) {
84
+ found.push(githubProvider(env["GITHUB_CLIENT_ID"], env["GITHUB_CLIENT_SECRET"]));
85
+ }
86
+ if (env["GOOGLE_CLIENT_ID"] && env["GOOGLE_CLIENT_SECRET"]) {
87
+ found.push(googleProvider(env["GOOGLE_CLIENT_ID"], env["GOOGLE_CLIENT_SECRET"]));
88
+ }
89
+ return found;
90
+ }
91
+ export function redirectUri(site, provider) {
92
+ return `${site.replace(/\/+$/, "")}/api/v1/${provider.id}/oauth/callback`;
93
+ }
94
+ /** Where to send the browser. `state` is the only thing standing between this and CSRF. */
95
+ export function authorizeUrl(provider, site, state) {
96
+ const url = new URL(provider.authorizeUrl);
97
+ url.searchParams.set("client_id", provider.clientId);
98
+ url.searchParams.set("redirect_uri", redirectUri(site, provider));
99
+ url.searchParams.set("response_type", "code");
100
+ url.searchParams.set("scope", provider.scope);
101
+ url.searchParams.set("state", state);
102
+ return url.toString();
103
+ }
104
+ /** Swap the code for an access token. Empty string means the provider refused. */
105
+ export async function exchangeCode(provider, code, site, send = fetch) {
106
+ const answer = await send(provider.tokenUrl, {
107
+ method: "POST",
108
+ headers: {
109
+ // GitHub answers form-encoded unless asked for JSON, which is the trap
110
+ // that makes a working exchange look like an empty token.
111
+ accept: "application/json",
112
+ "content-type": "application/x-www-form-urlencoded",
113
+ ...AGENT,
114
+ },
115
+ body: new URLSearchParams({
116
+ client_id: provider.clientId,
117
+ client_secret: provider.clientSecret,
118
+ code,
119
+ redirect_uri: redirectUri(site, provider),
120
+ grant_type: "authorization_code",
121
+ }).toString(),
122
+ });
123
+ const body = await readJson(answer);
124
+ return typeof body["access_token"] === "string" ? body["access_token"] : "";
125
+ }
126
+ // --- linking an identity to an account --------------------------------------
127
+ const TABLE = "nixamp_identities";
128
+ const SCHEMA = `
129
+ CREATE TABLE IF NOT EXISTS ${TABLE} (
130
+ provider TEXT NOT NULL,
131
+ subject TEXT NOT NULL,
132
+ user_id TEXT NOT NULL,
133
+ email TEXT NOT NULL DEFAULT '',
134
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
135
+ PRIMARY KEY (provider, subject)
136
+ );
137
+ `;
138
+ /**
139
+ * The account behind a provider identity, creating one the first time.
140
+ *
141
+ * The account row is made with no password at all rather than a random one
142
+ * nobody knows. A null password is a fact -- this account signs in with GitHub
143
+ * -- where a random password is a credential sitting in a database waiting to
144
+ * be found.
145
+ */
146
+ export class Identities {
147
+ db;
148
+ users;
149
+ ready = null;
150
+ constructor(db, users) {
151
+ this.db = db;
152
+ this.users = users;
153
+ }
154
+ async ensure() {
155
+ this.ready ??= this.db.query(SCHEMA).then(() => undefined);
156
+ await this.ready;
157
+ }
158
+ async resolve(identity) {
159
+ if (!identity.subject || !identity.email)
160
+ return null;
161
+ await this.ensure();
162
+ // Already linked. The subject is what is matched, not the address: people
163
+ // change the address on a GitHub account and stay the same person.
164
+ const { rows } = await this.db.query(`SELECT user_id, email FROM ${TABLE} WHERE provider = $1 AND subject = $2`, [
165
+ identity.provider,
166
+ identity.subject,
167
+ ]);
168
+ const linked = rows[0];
169
+ if (linked) {
170
+ const id = String(linked["user_id"] ?? "");
171
+ if (String(linked["email"] ?? "") !== identity.email) {
172
+ await this.db.query(`UPDATE ${TABLE} SET email = $3 WHERE provider = $1 AND subject = $2`, [
173
+ identity.provider,
174
+ identity.subject,
175
+ identity.email,
176
+ ]);
177
+ }
178
+ return { id, email: identity.email };
179
+ }
180
+ // Not linked, but the address may already have an account -- somebody who
181
+ // signed up with a password and is now signing in with GitHub. Linking on
182
+ // a verified address is what makes those the same account instead of two.
183
+ const existing = await this.users.getUserByEmail(identity.email);
184
+ const account = existing?.id
185
+ ? { id: existing.id, email: existing.email ?? identity.email }
186
+ : await (async () => {
187
+ const made = await this.users.createUser({
188
+ email: identity.email,
189
+ password: null,
190
+ // The provider verified it, which is the whole reason this is
191
+ // allowed to become an account without an email being sent.
192
+ emailVerified: true,
193
+ profile: { signedUpWith: identity.provider },
194
+ });
195
+ return { id: String(made.id ?? ""), email: made.email ?? identity.email };
196
+ })();
197
+ if (!account.id)
198
+ return null;
199
+ await this.db.query(`INSERT INTO ${TABLE} (provider, subject, user_id, email) VALUES ($1, $2, $3, $4)
200
+ ON CONFLICT (provider, subject) DO UPDATE SET email = EXCLUDED.email`, [identity.provider, identity.subject, account.id, identity.email]);
201
+ return account;
202
+ }
203
+ }
204
+ /** A state is only ever open for the length of one sign-in. */
205
+ export const STATE_TTL_MS = 600_000;
206
+ export class SignIn {
207
+ providers;
208
+ device;
209
+ site;
210
+ now;
211
+ states = new Map();
212
+ constructor(providers, device, site, now = Date.now) {
213
+ this.providers = providers;
214
+ this.device = device;
215
+ this.site = site;
216
+ this.now = now;
217
+ }
218
+ /** What /api/v1/auth/providers says, and what the CLI menu is built from. */
219
+ get offered() {
220
+ return this.providers.map((provider) => ({ id: provider.id, name: provider.name }));
221
+ }
222
+ provider(id) {
223
+ return this.providers.find((candidate) => candidate.id === id) ?? null;
224
+ }
225
+ /** Start a round trip, and answer the URL the browser should go to. */
226
+ begin(provider, userCode = "") {
227
+ this.sweep();
228
+ const state = randomBytes(24).toString("base64url");
229
+ this.states.set(state, { provider: provider.id, userCode, createdAt: this.now() });
230
+ return authorizeUrl(provider, this.site, state);
231
+ }
232
+ /** Redeem a state exactly once, so a replayed callback finds nothing. */
233
+ claim(state) {
234
+ if (typeof state !== "string" || state === "")
235
+ return null;
236
+ const pending = this.states.get(state);
237
+ if (!pending)
238
+ return null;
239
+ this.states.delete(state);
240
+ return this.now() - pending.createdAt > STATE_TTL_MS ? null : pending;
241
+ }
242
+ sweep() {
243
+ const at = this.now();
244
+ for (const [state, pending] of this.states) {
245
+ if (at - pending.createdAt > STATE_TTL_MS)
246
+ this.states.delete(state);
247
+ }
248
+ }
249
+ }
250
+ function escapeHtml(value) {
251
+ return value.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
252
+ }
253
+ const PAGE_STYLE = `
254
+ :root { color-scheme: dark }
255
+ body { background:#000; color:#00e676; font:16px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;
256
+ margin:0; min-height:100vh; display:grid; place-items:center; padding:2rem }
257
+ main { width:min(30rem,100%) }
258
+ h1 { font-size:1.1rem; letter-spacing:.2em; text-transform:uppercase; color:#9ad }
259
+ input { font:inherit; background:#111; color:#00e676; border:1px solid #2a2a2a; padding:.6rem .8rem;
260
+ width:100%; box-sizing:border-box; letter-spacing:.25em; text-transform:uppercase }
261
+ button { font:inherit; background:#111; color:#00e676; border:1px solid #2a2a2a; padding:.6rem 1rem;
262
+ cursor:pointer; width:100%; margin-top:.5rem; text-align:left }
263
+ button:hover { border-color:#00e676 }
264
+ p { color:#9a9a9a } code { color:#00e676 }
265
+ `;
266
+ function page(title, body) {
267
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8">
268
+ <meta name="viewport" content="width=device-width,initial-scale=1">
269
+ <title>${escapeHtml(title)} - nixamp</title><style>${PAGE_STYLE}</style></head>
270
+ <body><main>${body}</main></body></html>`;
271
+ }
272
+ /**
273
+ * The page the terminal sends somebody to.
274
+ *
275
+ * It is served by the API rather than the app because it has to work before
276
+ * there is a session and without the web build being present -- a nixamp
277
+ * deployed with no web assets can still sign a terminal in.
278
+ */
279
+ export function devicePage(signIn, code, signedInAs) {
280
+ const value = escapeHtml(code);
281
+ const choices = signIn.providers
282
+ .map((provider) => `<button type="submit" name="with" value="${escapeHtml(provider.id)}">Continue with ${escapeHtml(provider.name)}</button>`)
283
+ .join("");
284
+ const approve = signedInAs
285
+ ? `<button type="submit" name="with" value="">Approve as ${escapeHtml(signedInAs)}</button>`
286
+ : "";
287
+ return page("Connect a terminal", `<h1>Connect a terminal</h1>
288
+ <p>Your terminal is showing a code. Type it here, then choose how to sign in.</p>
289
+ <form method="POST" action="/api/v1/auth/device">
290
+ <input name="code" value="${value}" placeholder="XXXX-XXXX" autocomplete="off"
291
+ autocapitalize="characters" spellcheck="false" required>
292
+ ${approve}${choices}
293
+ </form>
294
+ ${signIn.providers.length === 0 && !signedInAs ? "<p>Sign in on this site first, then come back to this page.</p>" : ""}`);
295
+ }
296
+ export function deviceDonePage(email) {
297
+ return page("Terminal connected", `<h1>Terminal connected</h1>
298
+ <p>Signed in as <code>${escapeHtml(email)}</code>. Go back to your terminal; you can close this.</p>`);
299
+ }
300
+ export function signInFailedPage(why) {
301
+ return page("Sign-in failed", `<h1>Sign-in failed</h1><p>${escapeHtml(why)}</p>`);
302
+ }
package/dist/server.d.ts CHANGED
@@ -4,6 +4,7 @@ import { Broadcaster, type Destination, type EncoderSettings } from "./broadcast
4
4
  import { Ingest } from "./ingest.ts";
5
5
  import { Channels } from "./channels.ts";
6
6
  import { Accounts } from "./accounts.ts";
7
+ import { SignIn } from "./oauth.ts";
7
8
  import { Owner } from "./owner.ts";
8
9
  import { Directory } from "./directory.ts";
9
10
  import { PartyLine } from "./partyline.ts";
@@ -157,6 +158,11 @@ export declare class EmptyEngine implements Engine {
157
158
  replace(): void;
158
159
  stop(): void;
159
160
  }
161
+ /**
162
+ * Paths that are how somebody without a key gets one, so they answer before
163
+ * the share-key check rather than behind it.
164
+ */
165
+ export declare function isSignInPath(path: string): boolean;
160
166
  export interface HandlerOptions {
161
167
  web: string | null;
162
168
  media: boolean;
@@ -198,6 +204,8 @@ export interface HandlerOptions {
198
204
  };
199
205
  /** Accounts, on the instance that keeps them. Only nixamp.com passes this. */
200
206
  accounts?: Accounts;
207
+ /** Providers to sign in with, and the terminals waiting to be connected. */
208
+ signIn?: SignIn;
201
209
  /** True when this instance is reached over https, for the cookie's Secure. */
202
210
  secureCookies?: boolean;
203
211
  /** Who may administer this server. */
package/dist/server.js CHANGED
@@ -20,6 +20,8 @@ import { Ingest, normaliseFormat } from "./ingest.js";
20
20
  import { Channels, cleanId } from "./channels.js";
21
21
  import { RtmpListeners } from "./rtmp-in.js";
22
22
  import { Accounts, clearedCookie, sessionCookie, tokenFrom } from "./accounts.js";
23
+ import { DeviceGrants } from "./device.js";
24
+ import { deviceDonePage, devicePage, exchangeCode, providersFrom, signInFailedPage, SignIn, } from "./oauth.js";
23
25
  import { needsAdmin, Owner } from "./owner.js";
24
26
  import { readSession } from "./session.js";
25
27
  import { Directory, ENDED_TTL_MS, parseAnnouncement } from "./directory.js";
@@ -472,6 +474,23 @@ const CORS = {
472
474
  "access-control-allow-headers": "content-type",
473
475
  "access-control-max-age": "86400",
474
476
  };
477
+ /** /api/v1/<provider>/oauth/start and .../callback, the house callback shape. */
478
+ const OAUTH_ROUTE = /^\/api\/v1\/([a-z0-9-]+)\/oauth\/(start|callback)$/;
479
+ /**
480
+ * Paths that are how somebody without a key gets one, so they answer before
481
+ * the share-key check rather than behind it.
482
+ */
483
+ export function isSignInPath(path) {
484
+ return path.startsWith("/api/v1/auth/") || OAUTH_ROUTE.test(path);
485
+ }
486
+ function html(response, code, body) {
487
+ response.writeHead(code, {
488
+ "content-type": "text/html; charset=utf-8",
489
+ "content-length": Buffer.byteLength(body),
490
+ "cache-control": "no-store",
491
+ });
492
+ response.end(body);
493
+ }
475
494
  function json(response, code, body) {
476
495
  const text = JSON.stringify(body);
477
496
  response.writeHead(code, {
@@ -741,7 +760,7 @@ export function createHandler(engine, options) {
741
760
  if (key !== null &&
742
761
  path !== "/api/health" &&
743
762
  path !== "/api/directory" &&
744
- !path.startsWith("/api/v1/auth/")) {
763
+ !isSignInPath(path)) {
745
764
  const scope = scopeOf(keyFrom(request, url), key, listenKey);
746
765
  if (scope === null) {
747
766
  json(response, 401, { error: "this nixamp needs the key from its share link" });
@@ -774,6 +793,9 @@ export function createHandler(engine, options) {
774
793
  return;
775
794
  }
776
795
  if (path === "/api/v1/auth/logout") {
796
+ // The cookie going is what the browser notices; the token going is
797
+ // what makes it stop working on a machine you no longer have.
798
+ await accounts.endSession(tokenFrom(request.headers));
777
799
  response.writeHead(200, {
778
800
  ...CORS,
779
801
  "content-type": "application/json; charset=utf-8",
@@ -782,6 +804,150 @@ export function createHandler(engine, options) {
782
804
  response.end(JSON.stringify({ ok: true }));
783
805
  return;
784
806
  }
807
+ // What this deployment will accept, so the CLI offers the ways in that
808
+ // exist here rather than a menu built from what it hopes is configured.
809
+ if (path === "/api/v1/auth/providers") {
810
+ json(response, 200, {
811
+ password: true,
812
+ device: options.signIn !== undefined,
813
+ providers: options.signIn?.offered ?? [],
814
+ });
815
+ return;
816
+ }
817
+ // --- the device grant, for a terminal with no browser ---------------
818
+ if (path.startsWith("/api/v1/auth/device") && options.signIn) {
819
+ const signIn = options.signIn;
820
+ // A terminal asks for a code to show, and starts polling.
821
+ if (path === "/api/v1/auth/device/code" && request.method === "POST") {
822
+ const grant = signIn.device.start();
823
+ const where = `${signIn.site}/api/v1/auth/device`;
824
+ json(response, 200, {
825
+ device_code: grant.deviceCode,
826
+ user_code: grant.userCode,
827
+ verification_uri: where,
828
+ // The pre-filled link is what makes this one click on a phone.
829
+ verification_uri_complete: `${where}?code=${encodeURIComponent(grant.userCode)}`,
830
+ expires_in: Math.round((grant.expiresAt - Date.now()) / 1000),
831
+ interval: signIn.device.interval,
832
+ });
833
+ return;
834
+ }
835
+ // ... and asks, at that interval, whether anybody has approved it yet.
836
+ if (path === "/api/v1/auth/device/token" && request.method === "POST") {
837
+ let body;
838
+ try {
839
+ body = JSON.parse(await readBody(request));
840
+ }
841
+ catch {
842
+ json(response, 400, { error: "bad JSON" });
843
+ return;
844
+ }
845
+ const status = signIn.device.poll(String(body.device_code ?? ""));
846
+ if (status.status === "ok") {
847
+ json(response, 200, { token: status.token, email: status.email });
848
+ return;
849
+ }
850
+ // The names are RFC 8628's, because that is what a client waiting on
851
+ // a device grant already knows how to read.
852
+ const named = {
853
+ pending: "authorization_pending",
854
+ slow_down: "slow_down",
855
+ expired: "expired_token",
856
+ denied: "access_denied",
857
+ };
858
+ json(response, 400, { error: named[status.status] });
859
+ return;
860
+ }
861
+ // The page somebody opens on a device that has a keyboard.
862
+ if (path === "/api/v1/auth/device" && (request.method === "GET" || request.method === "HEAD")) {
863
+ const who = await accounts.whoIs(tokenFrom(request.headers));
864
+ html(response, 200, devicePage(signIn, url.searchParams.get("code") ?? "", who?.email ?? ""));
865
+ return;
866
+ }
867
+ if (path === "/api/v1/auth/device" && request.method === "POST") {
868
+ const form = new URLSearchParams(await readBody(request));
869
+ const code = form.get("code") ?? "";
870
+ const grant = signIn.device.find(code);
871
+ if (grant === null) {
872
+ html(response, 404, signInFailedPage("That code has expired or was already used. Ask your terminal for another."));
873
+ return;
874
+ }
875
+ // Empty means "approve as the account this browser is already signed
876
+ // in as"; anything else names a provider to go and ask.
877
+ //
878
+ // A form on somebody else's site posting here is what would make
879
+ // this dangerous, and is what SameSite=Lax on the session cookie
880
+ // prevents: a cross-site POST arrives with no cookie, so it is
881
+ // nobody, so it approves nothing.
882
+ const chosen = form.get("with") ?? "";
883
+ if (chosen === "") {
884
+ const who = await accounts.whoIs(tokenFrom(request.headers));
885
+ if (who === null) {
886
+ html(response, 401, signInFailedPage("Sign in first, then approve the terminal."));
887
+ return;
888
+ }
889
+ const token = await accounts.sessionFor(who);
890
+ if (!token || !signIn.device.approve(grant.userCode, { token, email: who.email })) {
891
+ html(response, 500, signInFailedPage("Could not start a session for that terminal."));
892
+ return;
893
+ }
894
+ html(response, 200, deviceDonePage(who.email));
895
+ return;
896
+ }
897
+ const provider = signIn.provider(chosen);
898
+ if (provider === null) {
899
+ html(response, 404, signInFailedPage("This nixamp cannot sign you in with that."));
900
+ return;
901
+ }
902
+ // The user code rides along in the state, so the callback knows it
903
+ // is approving a terminal rather than signing this browser in.
904
+ response.writeHead(302, { location: signIn.begin(provider, grant.userCode) });
905
+ response.end();
906
+ return;
907
+ }
908
+ json(response, 404, { error: "no such endpoint" });
909
+ return;
910
+ }
911
+ // --- tokens a person made on purpose --------------------------------
912
+ if (path === "/api/v1/auth/tokens" || path.startsWith("/api/v1/auth/tokens/")) {
913
+ const who = await accounts.whoIs(tokenFrom(request.headers));
914
+ if (who === null) {
915
+ json(response, 401, { error: "not signed in" });
916
+ return;
917
+ }
918
+ if (path === "/api/v1/auth/tokens" && request.method === "GET") {
919
+ json(response, 200, { tokens: await accounts.listTokens(who.id, "cli") });
920
+ return;
921
+ }
922
+ if (path === "/api/v1/auth/tokens" && request.method === "POST") {
923
+ let body;
924
+ try {
925
+ body = JSON.parse(await readBody(request));
926
+ }
927
+ catch {
928
+ json(response, 400, { error: "bad JSON" });
929
+ return;
930
+ }
931
+ const name = typeof body.name === "string" ? body.name.slice(0, 80) : "";
932
+ const made = await accounts.mintCliToken(who, name);
933
+ if (made === null) {
934
+ json(response, 501, { error: "this nixamp does not keep tokens" });
935
+ return;
936
+ }
937
+ // The whole token is in this answer and in no other: it is not
938
+ // stored, so there is nowhere to show it again from.
939
+ json(response, 201, { token: made.token, id: made.id, name: made.name });
940
+ return;
941
+ }
942
+ const id = path.slice("/api/v1/auth/tokens/".length);
943
+ if (id && request.method === "DELETE") {
944
+ const gone = await accounts.revokeToken(who.id, id);
945
+ json(response, gone ? 200 : 404, gone ? { ok: true } : { error: "no such token" });
946
+ return;
947
+ }
948
+ json(response, 405, { error: "GET, POST or DELETE" });
949
+ return;
950
+ }
785
951
  const signingUp = path === "/api/v1/auth/signup";
786
952
  if (!signingUp && path !== "/api/v1/auth/login") {
787
953
  json(response, 404, { error: "no such endpoint" });
@@ -807,14 +973,84 @@ export function createHandler(engine, options) {
807
973
  json(response, signingUp ? 409 : 401, { error: result.error });
808
974
  return;
809
975
  }
976
+ // A password sign-in ends in the same revocable token an OAuth one does,
977
+ // falling back to the module's JWT where there is no storage to keep one
978
+ // in. Every way in should be a session that can be listed and ended.
979
+ const token = result.account
980
+ ? await accounts.sessionFor(result.account, result.token)
981
+ : result.token;
810
982
  // The token goes back in the body for the CLI and the desktop app, and
811
983
  // as a cookie for the browser, which then needs to know nothing about it.
812
984
  response.writeHead(200, {
813
985
  ...CORS,
814
986
  "content-type": "application/json; charset=utf-8",
987
+ "set-cookie": sessionCookie(token, secure),
988
+ });
989
+ response.end(JSON.stringify({ account: result.account, token }));
990
+ return;
991
+ }
992
+ // --- coming back from a provider ---------------------------------------
993
+ //
994
+ // /api/v1/<provider>/oauth/start sends a browser away, and .../callback is
995
+ // what the provider was told to send it back to. Both are outside the
996
+ // /api/v1/auth/ block because that is the URL shape registered with GitHub
997
+ // and Google, and a redirect URI is not something to change lightly.
998
+ const oauthRoute = OAUTH_ROUTE.exec(path);
999
+ if (oauthRoute && options.accounts && options.signIn) {
1000
+ const accounts = options.accounts;
1001
+ const signIn = options.signIn;
1002
+ const secure = options.secureCookies ?? false;
1003
+ const provider = signIn.provider(oauthRoute[1]);
1004
+ if (provider === null) {
1005
+ json(response, 404, { error: "this nixamp cannot sign you in with that" });
1006
+ return;
1007
+ }
1008
+ if (oauthRoute[2] === "start") {
1009
+ // A terminal can link straight here with the code it is showing, which
1010
+ // is one hop shorter than the page for somebody who followed the link.
1011
+ const grant = signIn.device.find(url.searchParams.get("device") ?? "");
1012
+ response.writeHead(302, { location: signIn.begin(provider, grant?.userCode ?? "") });
1013
+ response.end();
1014
+ return;
1015
+ }
1016
+ // A callback carrying no state, or one whose state was already spent, is
1017
+ // not a sign-in: it is somebody replaying a URL they found.
1018
+ const pending = signIn.claim(url.searchParams.get("state"));
1019
+ if (pending === null || pending.provider !== provider.id) {
1020
+ html(response, 400, signInFailedPage("That sign-in link has expired. Start again."));
1021
+ return;
1022
+ }
1023
+ const code = url.searchParams.get("code") ?? "";
1024
+ if (!code) {
1025
+ html(response, 400, signInFailedPage(url.searchParams.get("error") ?? "The provider sent no code."));
1026
+ return;
1027
+ }
1028
+ const access = await exchangeCode(provider, code, signIn.site).catch(() => "");
1029
+ const identity = access ? await provider.identify(access, fetch).catch(() => null) : null;
1030
+ if (identity === null) {
1031
+ html(response, 401, signInFailedPage(`${provider.name} did not confirm a verified email address.`));
1032
+ return;
1033
+ }
1034
+ const result = await accounts.signInWith(identity);
1035
+ if (!result.ok || result.account === null) {
1036
+ html(response, 401, signInFailedPage(result.error || "Could not sign in."));
1037
+ return;
1038
+ }
1039
+ if (pending.userCode) {
1040
+ // This round trip was approving a terminal. The browser is finished;
1041
+ // the session belongs to whatever is polling.
1042
+ if (!signIn.device.approve(pending.userCode, { token: result.token, email: result.account.email })) {
1043
+ html(response, 410, signInFailedPage("That terminal stopped waiting. Run `nixamp login` again."));
1044
+ return;
1045
+ }
1046
+ html(response, 200, deviceDonePage(result.account.email));
1047
+ return;
1048
+ }
1049
+ response.writeHead(302, {
815
1050
  "set-cookie": sessionCookie(result.token, secure),
1051
+ location: "/",
816
1052
  });
817
- response.end(JSON.stringify({ account: result.account, token: result.token }));
1053
+ response.end();
818
1054
  return;
819
1055
  }
820
1056
  // The directory is public to read and answered before the key check,
@@ -1785,6 +2021,10 @@ export async function serve(argv, version = "0.1.0") {
1785
2021
  secret: process.env["NIXAMP_JWT_SECRET"] ?? "",
1786
2022
  }),
1787
2023
  secureCookies: (process.env["NIXAMP_SITE"] ?? "").startsWith("https://"),
2024
+ // Whichever providers this deployment was given both halves of, plus
2025
+ // the device grant, which is worth having even with no provider at
2026
+ // all: a browser already signed in can approve a terminal.
2027
+ signIn: new SignIn(providersFrom(process.env), new DeviceGrants(), process.env["NIXAMP_SITE"] ?? DEFAULT_DIRECTORY),
1788
2028
  }
1789
2029
  : {}),
1790
2030
  });