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