Skip to content

Commit 1750e91

Browse files
committed
implement handling ephemeral registration token
1 parent 61d34e6 commit 1750e91

File tree

5 files changed

+125
-21
lines changed

5 files changed

+125
-21
lines changed
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/*
2+
* This file is part of CoCalc: Copyright © 2024 Sagemath, Inc.
3+
* License: MS-RSL – see LICENSE.md for details
4+
*/
5+
6+
import { v4 } from "uuid";
7+
8+
import createAccount from "@cocalc/server/accounts/create-account";
9+
import redeemRegistrationToken from "@cocalc/server/auth/tokens/redeem";
10+
import { signUserIn } from "./sign-in";
11+
import getParams from "lib/api/get-params";
12+
13+
export default async function createEphemeralAccount(req, res) {
14+
let { registrationToken } = getParams(req);
15+
registrationToken = (registrationToken ?? "").trim();
16+
if (!registrationToken) {
17+
res.json({ error: "Registration token required." });
18+
return;
19+
}
20+
let tokenInfo;
21+
try {
22+
tokenInfo = await redeemRegistrationToken(registrationToken);
23+
} catch (err) {
24+
res.json({
25+
error: `Issue with registration token -- ${err.message}`,
26+
});
27+
return;
28+
}
29+
if (!tokenInfo?.ephemeral || tokenInfo.ephemeral <= 0) {
30+
res.json({
31+
error:
32+
"This registration token is not configured for ephemeral accounts.",
33+
});
34+
return;
35+
}
36+
37+
const account_id = v4();
38+
const suffix = account_id.slice(0, 6);
39+
try {
40+
await createAccount({
41+
email: undefined,
42+
password: undefined,
43+
firstName: "Ephemeral",
44+
lastName: `User-${suffix}`,
45+
account_id,
46+
tags: ["ephemeral"],
47+
signupReason: "ephemeral",
48+
ephemeral: tokenInfo.ephemeral,
49+
});
50+
} catch (err) {
51+
res.json({
52+
error: `Problem creating ephemeral account -- ${err.message}`,
53+
});
54+
return;
55+
}
56+
57+
await signUserIn(req, res, account_id, { maxAge: tokenInfo.ephemeral });
58+
}

src/packages/next/pages/api/v2/auth/sign-in.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,12 +81,18 @@ export async function getAccount(
8181
return account_id;
8282
}
8383

84-
export async function signUserIn(req, res, account_id: string): Promise<void> {
84+
export async function signUserIn(
85+
req,
86+
res,
87+
account_id: string,
88+
opts?: { maxAge?: number },
89+
): Promise<void> {
8590
try {
8691
await setSignInCookies({
8792
req,
8893
res,
8994
account_id,
95+
maxAge: opts?.maxAge,
9096
});
9197
} catch (err) {
9298
res.json({ error: `Problem setting auth cookies -- ${err}` });

src/packages/next/pages/ephemeral.tsx

Lines changed: 40 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,42 +9,55 @@ import Footer from "components/landing/footer";
99
import Header from "components/landing/header";
1010
import Head from "components/landing/head";
1111
import { Icon } from "@cocalc/frontend/components/icon";
12+
import apiPost from "lib/api/post";
1213
import { Customize } from "lib/customize";
1314
import withCustomize from "lib/with-customize";
15+
import { useRouter } from "next/router";
1416

1517
const { Paragraph, Text, Title } = Typography;
1618

17-
type Status = "idle" | "verifying";
19+
type Status = "idle" | "verifying" | "redirecting";
1820

1921
interface Props {
2022
customize;
2123
token?: string;
2224
}
2325

2426
export default function EphemeralPage({ customize, token }: Props) {
27+
const router = useRouter();
2528
const [registrationToken, setRegistrationToken] = useState<string>(
2629
token ?? "",
2730
);
2831
const [status, setStatus] = useState<Status>("idle");
2932
const [info, setInfo] = useState<string>("");
33+
const [error, setError] = useState<string>("");
3034

3135
useEffect(() => {
3236
if (token) {
3337
setRegistrationToken(token);
3438
}
3539
}, [token]);
3640

37-
const disabled = registrationToken.trim().length === 0 || status !== "idle";
41+
const trimmedToken = registrationToken.trim();
42+
const working = status !== "idle";
43+
const disabled = trimmedToken.length === 0 || working;
3844

39-
function handleConfirm(): void {
45+
async function handleConfirm(): Promise<void> {
46+
if (disabled) return;
4047
setStatus("verifying");
41-
// Placeholder wiring – actual validation happens in the next task.
42-
setTimeout(() => {
48+
setInfo("");
49+
setError("");
50+
try {
51+
await apiPost("/auth/ephemeral", {
52+
registrationToken: trimmedToken,
53+
});
54+
setStatus("redirecting");
55+
setInfo("Success! Redirecting you to your workspace…");
56+
await router.push("/app");
57+
} catch (err) {
58+
setError(err?.message ?? `${err}`);
4359
setStatus("idle");
44-
setInfo(
45-
"Token validation isn't wired up yet. Once implemented, this button will create an ephemeral account.",
46-
);
47-
}, 250);
60+
}
4861
}
4962

5063
return (
@@ -94,11 +107,23 @@ export default function EphemeralPage({ customize, token }: Props) {
94107
onChange={(e) => {
95108
setRegistrationToken(e.target.value);
96109
if (info) setInfo("");
110+
if (error) setError("");
97111
}}
98112
onPressEnter={disabled ? undefined : handleConfirm}
99113
/>
100114
</div>
101115

116+
{error && (
117+
<Alert
118+
type="error"
119+
message="Unable to create account"
120+
description={error}
121+
showIcon
122+
closable
123+
onClose={() => setError("")}
124+
/>
125+
)}
126+
102127
{info && (
103128
<Alert
104129
type="info"
@@ -113,11 +138,11 @@ export default function EphemeralPage({ customize, token }: Props) {
113138
type="primary"
114139
size="large"
115140
disabled={disabled}
116-
loading={status === "verifying"}
141+
loading={working}
117142
onClick={handleConfirm}
118143
block
119144
>
120-
Continue
145+
{status === "redirecting" ? "Redirecting…" : "Continue"}
121146
</Button>
122147

123148
<Alert
@@ -127,9 +152,10 @@ export default function EphemeralPage({ customize, token }: Props) {
127152
description={
128153
<Paragraph style={{ marginBottom: 0 }}>
129154
Ephemeral accounts automatically expire after the duration
130-
configured with their registration token. You will be
131-
signed in automatically and redirected to your workspace
132-
as soon as token verification is implemented.
155+
configured with their registration token. When the token
156+
is valid you'll be signed in automatically and redirected
157+
to your workspace with a cookie that expires at the same
158+
time.
133159
</Paragraph>
134160
}
135161
/>

src/packages/server/accounts/create-account.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ import { getServerSettings } from "@cocalc/database/settings/server-settings";
1515
const log = getLogger("server:accounts:create");
1616

1717
interface Params {
18-
email: string;
19-
password: string;
18+
email?: string;
19+
password?: string;
2020
firstName: string;
2121
lastName: string;
2222
account_id: string;
@@ -27,6 +27,7 @@ interface Params {
2727
// I added this to avoid leaks with unit testing, but it may be useful in other contexts, e.g.,
2828
// avoiding confusion with self-hosted installs.
2929
noFirstProject?: boolean;
30+
ephemeral?: number;
3031
}
3132

3233
export default async function createAccount({
@@ -39,6 +40,7 @@ export default async function createAccount({
3940
signupReason,
4041
owner_id,
4142
noFirstProject,
43+
ephemeral,
4244
}: Params): Promise<void> {
4345
try {
4446
log.debug(
@@ -52,7 +54,7 @@ export default async function createAccount({
5254
);
5355
const pool = getPool();
5456
await pool.query(
55-
"INSERT INTO accounts (email_address, password_hash, first_name, last_name, account_id, created, tags, sign_up_usage_intent, owner_id) VALUES($1::TEXT, $2::TEXT, $3::TEXT, $4::TEXT, $5::UUID, NOW(), $6::TEXT[], $7::TEXT, $8::UUID)",
57+
"INSERT INTO accounts (email_address, password_hash, first_name, last_name, account_id, created, tags, sign_up_usage_intent, owner_id, ephemeral) VALUES($1::TEXT, $2::TEXT, $3::TEXT, $4::TEXT, $5::UUID, NOW(), $6::TEXT[], $7::TEXT, $8::UUID, $9::BIGINT)",
5658
[
5759
email ? email : undefined, // can't insert "" more than once!
5860
password ? passwordHash(password) : undefined, // definitely don't set password_hash to hash of empty string, e.g., anonymous accounts can then NEVER switch to email/password. This was a bug in production for a while.
@@ -62,6 +64,7 @@ export default async function createAccount({
6264
tags,
6365
signupReason,
6466
owner_id,
67+
ephemeral ?? null,
6568
],
6669
);
6770
const { insecure_test_mode } = await getServerSettings();
@@ -85,4 +88,3 @@ export default async function createAccount({
8588
throw error; // re-throw to bubble up to higher layers if needed
8689
}
8790
}
88-

src/packages/server/auth/tokens/redeem.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,14 @@ then returns no matter what the input is.
99
import getRequiresTokens from "./get-requires-token";
1010
import { getTransactionClient } from "@cocalc/database/pool";
1111

12-
export default async function redeem(token: string): Promise<void> {
12+
export interface RegistrationTokenInfo {
13+
token: string;
14+
ephemeral?: number;
15+
}
16+
17+
export default async function redeem(
18+
token: string,
19+
): Promise<RegistrationTokenInfo | undefined> {
1320
const required = await getRequiresTokens();
1421
if (!required) {
1522
// no token required, so nothing to do.
@@ -29,7 +36,7 @@ export default async function redeem(token: string): Promise<void> {
2936
// → if counter, check counter vs. limit
3037
// → true: increase the counter → ok
3138
// → false: ok
32-
const q_match = `SELECT "expires", "counter", "limit", "disabled"
39+
const q_match = `SELECT "expires", "counter", "limit", "disabled", "ephemeral"
3340
FROM registration_tokens
3441
WHERE token = $1::TEXT
3542
FOR UPDATE`;
@@ -44,6 +51,7 @@ export default async function redeem(token: string): Promise<void> {
4451
counter: counter_raw,
4552
limit,
4653
disabled: disabled_raw,
54+
ephemeral,
4755
} = match.rows[0];
4856
const counter = counter_raw ?? 0;
4957
const disabled = disabled_raw ?? false;
@@ -68,6 +76,10 @@ export default async function redeem(token: string): Promise<void> {
6876

6977
// all good, let's commit
7078
await client.query("COMMIT");
79+
return {
80+
token,
81+
ephemeral: typeof ephemeral === "number" ? ephemeral : undefined,
82+
};
7183
} catch (err) {
7284
await client.query("ROLLBACK");
7385
throw err;

0 commit comments

Comments
 (0)