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/src/server.ts CHANGED
@@ -27,6 +27,15 @@ import { Ingest, normaliseFormat } from "./ingest.ts";
27
27
  import { Channels, cleanId } from "./channels.ts";
28
28
  import { RtmpListeners } from "./rtmp-in.ts";
29
29
  import { Accounts, clearedCookie, sessionCookie, tokenFrom } from "./accounts.ts";
30
+ import { DeviceGrants } from "./device.ts";
31
+ import {
32
+ deviceDonePage,
33
+ devicePage,
34
+ exchangeCode,
35
+ providersFrom,
36
+ signInFailedPage,
37
+ SignIn,
38
+ } from "./oauth.ts";
30
39
  import { needsAdmin, Owner } from "./owner.ts";
31
40
  import { readSession } from "./session.ts";
32
41
  import { Directory, ENDED_TTL_MS, parseAnnouncement, type Listing } from "./directory.ts";
@@ -563,6 +572,26 @@ const CORS: Record<string, string> = {
563
572
  "access-control-max-age": "86400",
564
573
  };
565
574
 
575
+ /** /api/v1/<provider>/oauth/start and .../callback, the house callback shape. */
576
+ const OAUTH_ROUTE = /^\/api\/v1\/([a-z0-9-]+)\/oauth\/(start|callback)$/;
577
+
578
+ /**
579
+ * Paths that are how somebody without a key gets one, so they answer before
580
+ * the share-key check rather than behind it.
581
+ */
582
+ export function isSignInPath(path: string): boolean {
583
+ return path.startsWith("/api/v1/auth/") || OAUTH_ROUTE.test(path);
584
+ }
585
+
586
+ function html(response: ServerResponse, code: number, body: string): void {
587
+ response.writeHead(code, {
588
+ "content-type": "text/html; charset=utf-8",
589
+ "content-length": Buffer.byteLength(body),
590
+ "cache-control": "no-store",
591
+ });
592
+ response.end(body);
593
+ }
594
+
566
595
  function json(response: ServerResponse, code: number, body: unknown): void {
567
596
  const text = JSON.stringify(body);
568
597
  response.writeHead(code, {
@@ -624,6 +653,8 @@ export interface HandlerOptions {
624
653
  broadcast?: () => { destinations: Destination[]; settings: EncoderSettings };
625
654
  /** Accounts, on the instance that keeps them. Only nixamp.com passes this. */
626
655
  accounts?: Accounts;
656
+ /** Providers to sign in with, and the terminals waiting to be connected. */
657
+ signIn?: SignIn;
627
658
  /** True when this instance is reached over https, for the cookie's Secure. */
628
659
  secureCookies?: boolean;
629
660
  /** Who may administer this server. */
@@ -918,7 +949,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
918
949
  key !== null &&
919
950
  path !== "/api/health" &&
920
951
  path !== "/api/directory" &&
921
- !path.startsWith("/api/v1/auth/")
952
+ !isSignInPath(path)
922
953
  ) {
923
954
  const scope = scopeOf(keyFrom(request, url), key, listenKey);
924
955
  if (scope === null) {
@@ -956,6 +987,9 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
956
987
  }
957
988
 
958
989
  if (path === "/api/v1/auth/logout") {
990
+ // The cookie going is what the browser notices; the token going is
991
+ // what makes it stop working on a machine you no longer have.
992
+ await accounts.endSession(tokenFrom(request.headers));
959
993
  response.writeHead(200, {
960
994
  ...CORS,
961
995
  "content-type": "application/json; charset=utf-8",
@@ -965,6 +999,162 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
965
999
  return;
966
1000
  }
967
1001
 
1002
+ // What this deployment will accept, so the CLI offers the ways in that
1003
+ // exist here rather than a menu built from what it hopes is configured.
1004
+ if (path === "/api/v1/auth/providers") {
1005
+ json(response, 200, {
1006
+ password: true,
1007
+ device: options.signIn !== undefined,
1008
+ providers: options.signIn?.offered ?? [],
1009
+ });
1010
+ return;
1011
+ }
1012
+
1013
+ // --- the device grant, for a terminal with no browser ---------------
1014
+ if (path.startsWith("/api/v1/auth/device") && options.signIn) {
1015
+ const signIn = options.signIn;
1016
+
1017
+ // A terminal asks for a code to show, and starts polling.
1018
+ if (path === "/api/v1/auth/device/code" && request.method === "POST") {
1019
+ const grant = signIn.device.start();
1020
+ const where = `${signIn.site}/api/v1/auth/device`;
1021
+ json(response, 200, {
1022
+ device_code: grant.deviceCode,
1023
+ user_code: grant.userCode,
1024
+ verification_uri: where,
1025
+ // The pre-filled link is what makes this one click on a phone.
1026
+ verification_uri_complete: `${where}?code=${encodeURIComponent(grant.userCode)}`,
1027
+ expires_in: Math.round((grant.expiresAt - Date.now()) / 1000),
1028
+ interval: signIn.device.interval,
1029
+ });
1030
+ return;
1031
+ }
1032
+
1033
+ // ... and asks, at that interval, whether anybody has approved it yet.
1034
+ if (path === "/api/v1/auth/device/token" && request.method === "POST") {
1035
+ let body: { device_code?: unknown };
1036
+ try {
1037
+ body = JSON.parse(await readBody(request)) as typeof body;
1038
+ } catch {
1039
+ json(response, 400, { error: "bad JSON" });
1040
+ return;
1041
+ }
1042
+ const status = signIn.device.poll(String(body.device_code ?? ""));
1043
+ if (status.status === "ok") {
1044
+ json(response, 200, { token: status.token, email: status.email });
1045
+ return;
1046
+ }
1047
+ // The names are RFC 8628's, because that is what a client waiting on
1048
+ // a device grant already knows how to read.
1049
+ const named = {
1050
+ pending: "authorization_pending",
1051
+ slow_down: "slow_down",
1052
+ expired: "expired_token",
1053
+ denied: "access_denied",
1054
+ } as const;
1055
+ json(response, 400, { error: named[status.status] });
1056
+ return;
1057
+ }
1058
+
1059
+ // The page somebody opens on a device that has a keyboard.
1060
+ if (path === "/api/v1/auth/device" && (request.method === "GET" || request.method === "HEAD")) {
1061
+ const who = await accounts.whoIs(tokenFrom(request.headers));
1062
+ html(response, 200, devicePage(signIn, url.searchParams.get("code") ?? "", who?.email ?? ""));
1063
+ return;
1064
+ }
1065
+
1066
+ if (path === "/api/v1/auth/device" && request.method === "POST") {
1067
+ const form = new URLSearchParams(await readBody(request));
1068
+ const code = form.get("code") ?? "";
1069
+ const grant = signIn.device.find(code);
1070
+ if (grant === null) {
1071
+ html(response, 404, signInFailedPage("That code has expired or was already used. Ask your terminal for another."));
1072
+ return;
1073
+ }
1074
+
1075
+ // Empty means "approve as the account this browser is already signed
1076
+ // in as"; anything else names a provider to go and ask.
1077
+ //
1078
+ // A form on somebody else's site posting here is what would make
1079
+ // this dangerous, and is what SameSite=Lax on the session cookie
1080
+ // prevents: a cross-site POST arrives with no cookie, so it is
1081
+ // nobody, so it approves nothing.
1082
+ const chosen = form.get("with") ?? "";
1083
+ if (chosen === "") {
1084
+ const who = await accounts.whoIs(tokenFrom(request.headers));
1085
+ if (who === null) {
1086
+ html(response, 401, signInFailedPage("Sign in first, then approve the terminal."));
1087
+ return;
1088
+ }
1089
+ const token = await accounts.sessionFor(who);
1090
+ if (!token || !signIn.device.approve(grant.userCode, { token, email: who.email })) {
1091
+ html(response, 500, signInFailedPage("Could not start a session for that terminal."));
1092
+ return;
1093
+ }
1094
+ html(response, 200, deviceDonePage(who.email));
1095
+ return;
1096
+ }
1097
+
1098
+ const provider = signIn.provider(chosen);
1099
+ if (provider === null) {
1100
+ html(response, 404, signInFailedPage("This nixamp cannot sign you in with that."));
1101
+ return;
1102
+ }
1103
+ // The user code rides along in the state, so the callback knows it
1104
+ // is approving a terminal rather than signing this browser in.
1105
+ response.writeHead(302, { location: signIn.begin(provider, grant.userCode) });
1106
+ response.end();
1107
+ return;
1108
+ }
1109
+
1110
+ json(response, 404, { error: "no such endpoint" });
1111
+ return;
1112
+ }
1113
+
1114
+ // --- tokens a person made on purpose --------------------------------
1115
+ if (path === "/api/v1/auth/tokens" || path.startsWith("/api/v1/auth/tokens/")) {
1116
+ const who = await accounts.whoIs(tokenFrom(request.headers));
1117
+ if (who === null) {
1118
+ json(response, 401, { error: "not signed in" });
1119
+ return;
1120
+ }
1121
+
1122
+ if (path === "/api/v1/auth/tokens" && request.method === "GET") {
1123
+ json(response, 200, { tokens: await accounts.listTokens(who.id, "cli") });
1124
+ return;
1125
+ }
1126
+
1127
+ if (path === "/api/v1/auth/tokens" && request.method === "POST") {
1128
+ let body: { name?: unknown };
1129
+ try {
1130
+ body = JSON.parse(await readBody(request)) as typeof body;
1131
+ } catch {
1132
+ json(response, 400, { error: "bad JSON" });
1133
+ return;
1134
+ }
1135
+ const name = typeof body.name === "string" ? body.name.slice(0, 80) : "";
1136
+ const made = await accounts.mintCliToken(who, name);
1137
+ if (made === null) {
1138
+ json(response, 501, { error: "this nixamp does not keep tokens" });
1139
+ return;
1140
+ }
1141
+ // The whole token is in this answer and in no other: it is not
1142
+ // stored, so there is nowhere to show it again from.
1143
+ json(response, 201, { token: made.token, id: made.id, name: made.name });
1144
+ return;
1145
+ }
1146
+
1147
+ const id = path.slice("/api/v1/auth/tokens/".length);
1148
+ if (id && request.method === "DELETE") {
1149
+ const gone = await accounts.revokeToken(who.id, id);
1150
+ json(response, gone ? 200 : 404, gone ? { ok: true } : { error: "no such token" });
1151
+ return;
1152
+ }
1153
+
1154
+ json(response, 405, { error: "GET, POST or DELETE" });
1155
+ return;
1156
+ }
1157
+
968
1158
  const signingUp = path === "/api/v1/auth/signup";
969
1159
  if (!signingUp && path !== "/api/v1/auth/login") {
970
1160
  json(response, 404, { error: "no such endpoint" });
@@ -993,14 +1183,92 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
993
1183
  return;
994
1184
  }
995
1185
 
1186
+ // A password sign-in ends in the same revocable token an OAuth one does,
1187
+ // falling back to the module's JWT where there is no storage to keep one
1188
+ // in. Every way in should be a session that can be listed and ended.
1189
+ const token = result.account
1190
+ ? await accounts.sessionFor(result.account, result.token)
1191
+ : result.token;
1192
+
996
1193
  // The token goes back in the body for the CLI and the desktop app, and
997
1194
  // as a cookie for the browser, which then needs to know nothing about it.
998
1195
  response.writeHead(200, {
999
1196
  ...CORS,
1000
1197
  "content-type": "application/json; charset=utf-8",
1198
+ "set-cookie": sessionCookie(token, secure),
1199
+ });
1200
+ response.end(JSON.stringify({ account: result.account, token }));
1201
+ return;
1202
+ }
1203
+
1204
+ // --- coming back from a provider ---------------------------------------
1205
+ //
1206
+ // /api/v1/<provider>/oauth/start sends a browser away, and .../callback is
1207
+ // what the provider was told to send it back to. Both are outside the
1208
+ // /api/v1/auth/ block because that is the URL shape registered with GitHub
1209
+ // and Google, and a redirect URI is not something to change lightly.
1210
+ const oauthRoute = OAUTH_ROUTE.exec(path);
1211
+ if (oauthRoute && options.accounts && options.signIn) {
1212
+ const accounts = options.accounts;
1213
+ const signIn = options.signIn;
1214
+ const secure = options.secureCookies ?? false;
1215
+ const provider = signIn.provider(oauthRoute[1]);
1216
+ if (provider === null) {
1217
+ json(response, 404, { error: "this nixamp cannot sign you in with that" });
1218
+ return;
1219
+ }
1220
+
1221
+ if (oauthRoute[2] === "start") {
1222
+ // A terminal can link straight here with the code it is showing, which
1223
+ // is one hop shorter than the page for somebody who followed the link.
1224
+ const grant = signIn.device.find(url.searchParams.get("device") ?? "");
1225
+ response.writeHead(302, { location: signIn.begin(provider, grant?.userCode ?? "") });
1226
+ response.end();
1227
+ return;
1228
+ }
1229
+
1230
+ // A callback carrying no state, or one whose state was already spent, is
1231
+ // not a sign-in: it is somebody replaying a URL they found.
1232
+ const pending = signIn.claim(url.searchParams.get("state"));
1233
+ if (pending === null || pending.provider !== provider.id) {
1234
+ html(response, 400, signInFailedPage("That sign-in link has expired. Start again."));
1235
+ return;
1236
+ }
1237
+ const code = url.searchParams.get("code") ?? "";
1238
+ if (!code) {
1239
+ html(response, 400, signInFailedPage(url.searchParams.get("error") ?? "The provider sent no code."));
1240
+ return;
1241
+ }
1242
+
1243
+ const access = await exchangeCode(provider, code, signIn.site).catch(() => "");
1244
+ const identity = access ? await provider.identify(access, fetch).catch(() => null) : null;
1245
+ if (identity === null) {
1246
+ html(response, 401, signInFailedPage(`${provider.name} did not confirm a verified email address.`));
1247
+ return;
1248
+ }
1249
+
1250
+ const result = await accounts.signInWith(identity);
1251
+ if (!result.ok || result.account === null) {
1252
+ html(response, 401, signInFailedPage(result.error || "Could not sign in."));
1253
+ return;
1254
+ }
1255
+
1256
+ if (pending.userCode) {
1257
+ // This round trip was approving a terminal. The browser is finished;
1258
+ // the session belongs to whatever is polling.
1259
+ if (!signIn.device.approve(pending.userCode, { token: result.token, email: result.account.email })) {
1260
+ html(response, 410, signInFailedPage("That terminal stopped waiting. Run `nixamp login` again."));
1261
+ return;
1262
+ }
1263
+ html(response, 200, deviceDonePage(result.account.email));
1264
+ return;
1265
+ }
1266
+
1267
+ response.writeHead(302, {
1001
1268
  "set-cookie": sessionCookie(result.token, secure),
1269
+ location: "/",
1002
1270
  });
1003
- response.end(JSON.stringify({ account: result.account, token: result.token }));
1271
+ response.end();
1004
1272
  return;
1005
1273
  }
1006
1274
 
@@ -2030,6 +2298,14 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
2030
2298
  secret: process.env["NIXAMP_JWT_SECRET"] ?? "",
2031
2299
  }),
2032
2300
  secureCookies: (process.env["NIXAMP_SITE"] ?? "").startsWith("https://"),
2301
+ // Whichever providers this deployment was given both halves of, plus
2302
+ // the device grant, which is worth having even with no provider at
2303
+ // all: a browser already signed in can approve a terminal.
2304
+ signIn: new SignIn(
2305
+ providersFrom(process.env),
2306
+ new DeviceGrants(),
2307
+ process.env["NIXAMP_SITE"] ?? DEFAULT_DIRECTORY,
2308
+ ),
2033
2309
  }
2034
2310
  : {}),
2035
2311
  });