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/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
|
|
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(
|
|
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
|
});
|