forked from supabase/cli
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathlogin.handler.ts
More file actions
245 lines (215 loc) · 8.89 KB
/
Copy pathlogin.handler.ts
File metadata and controls
245 lines (215 loc) · 8.89 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
import { Data, Effect, Exit, Option, Redacted } from "effect";
import { Url, UrlParams } from "effect/unstable/http";
import { validateToken } from "../../auth/token.ts";
import { CliConfig } from "../../config/cli-config.service.ts";
import { Output } from "../../../shared/output/output.service.ts";
import { Api } from "../../auth/api.service.ts";
import type { LoginSessionResponse } from "../../auth/api.service.ts";
import type { ApiError } from "../../auth/errors.ts";
import { Credentials } from "../../auth/credentials.service.ts";
import { Crypto } from "../../auth/crypto.service.ts";
import { Browser } from "../../../shared/runtime/browser.service.ts";
import { Stdin } from "../../../shared/runtime/stdin.service.ts";
import { getConfigDir } from "../../../shared/telemetry/consent.ts";
import {
clearDistinctId,
isEphemeralIdentityRuntime,
saveDistinctId,
} from "../../../shared/telemetry/identity.ts";
import { Analytics } from "../../../shared/telemetry/analytics.service.ts";
import { withAnalyticsContext } from "../../../shared/telemetry/analytics-context.ts";
import { TelemetryRuntime } from "../../../shared/telemetry/runtime.service.ts";
import type { NonInteractiveError } from "../../../shared/output/errors.ts";
import { LoginFailedError, NoTtyError } from "./login.errors.ts";
import type { LoginFlags } from "./login.command.ts";
class LoginVerificationError extends Data.TaggedError("LoginVerificationError")<{
cause: ApiError;
}> {}
const MAX_LOGIN_VERIFICATION_RETRIES = 2;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const revealToken = (token: Redacted.Redacted<string>): string => Redacted.value(token);
const resolveAuthenticatedDistinctId = Effect.fnUntraced(function* (
token: Redacted.Redacted<string>,
) {
const api = yield* Api;
const analytics = yield* Analytics;
const cliConfig = yield* CliConfig;
const telemetryRuntime = yield* TelemetryRuntime;
const configDir = yield* getConfigDir;
const profileExit = yield* api.fetchProfile(cliConfig.apiUrl, token).pipe(Effect.exit);
if (Exit.isFailure(profileExit)) {
telemetryRuntime.identity.clear();
yield* clearDistinctId(configDir);
return Option.none<string>();
}
// The in-memory stamp always happens so subsequent captures in this process
// carry the user's id; the alias (which merges pre-login device history) and
// the telemetry.json write only happen where the file survives between runs.
// See docs/adr/0013-hybrid-stitch-stamp-identity-attribution.md.
const distinctId = profileExit.value.gotrue_id;
// Alias only the first identity this device ever sees — re-aliasing on
// re-login would merge a second user into the device's person graph.
const current = telemetryRuntime.identity.current();
const firstIdentity = current === undefined || current.length === 0;
telemetryRuntime.identity.stamp(distinctId);
if (!isEphemeralIdentityRuntime(telemetryRuntime)) {
if (firstIdentity) {
yield* analytics.alias(distinctId, telemetryRuntime.deviceId);
}
yield* saveDistinctId(configDir, distinctId);
}
return Option.some(distinctId);
});
const captureLoginCompleted = Effect.fnUntraced(function* (
properties: Record<string, unknown>,
distinctId: Option.Option<string>,
) {
const analytics = yield* Analytics;
const capture = analytics.capture("cli_login_completed", properties);
if (Option.isNone(distinctId)) {
yield* capture;
return;
}
yield* capture.pipe(
withAnalyticsContext({
distinct_id: distinctId.value,
}),
);
});
const saveDirectToken = Effect.fnUntraced(function* (token: Redacted.Redacted<string>) {
const credentials = yield* Credentials;
const output = yield* Output;
yield* validateToken(revealToken(token));
yield* credentials.saveAccessToken(token);
const distinctId = yield* resolveAuthenticatedDistinctId(token);
yield* output.success("Logged in successfully.", { command: "login" });
yield* captureLoginCompleted({ login_method: "token" }, distinctId);
});
// Token resolution priority: --token flag > SUPABASE_ACCESS_TOKEN env > piped stdin > interactive browser flow
const resolveToken = Effect.fnUntraced(function* (tokenFlag: Option.Option<string>) {
if (Option.isSome(tokenFlag)) return Option.some(Redacted.make(tokenFlag.value));
const cliConfig = yield* CliConfig;
if (Option.isSome(cliConfig.accessToken)) return cliConfig.accessToken;
const stdin = yield* Stdin;
if (!stdin.isTTY) {
const piped = yield* stdin.readPipedText;
if (Option.isSome(piped)) return Option.some(Redacted.make(piped.value));
return yield* new NoTtyError({
detail: "Cannot prompt for token in non-interactive mode",
suggestion: "Pass --token or set SUPABASE_ACCESS_TOKEN",
});
}
return Option.none();
});
// ---------------------------------------------------------------------------
// Browser OAuth flow
// ---------------------------------------------------------------------------
const browserOAuthFlow = Effect.fnUntraced(function* (flags: LoginFlags) {
const credentials = yield* Credentials;
const api = yield* Api;
const crypto = yield* Crypto;
const browser = yield* Browser;
const output = yield* Output;
// Check if already logged in
const existingToken = yield* credentials.getAccessToken;
if (Option.isSome(existingToken)) {
yield* output.warn("You are already logged in.");
const shouldContinue = yield* output.promptConfirm(
"Do you want to log in with a different account?",
);
if (!shouldContinue) {
yield* output.outro("Already logged in.");
return;
}
}
const cliConfig = yield* CliConfig;
const apiUrl = cliConfig.apiUrl;
const dashboardUrl = cliConfig.dashboardUrl;
const { ecdh, publicKeyHex } = yield* crypto.generateKeyPair;
const sessionId = yield* crypto.generateSessionId;
const tokenName = Option.isSome(flags.name) ? flags.name.value : yield* crypto.defaultTokenName;
const loginUrl = yield* Url.make(
`${dashboardUrl}/cli/login`,
UrlParams.fromInput({
session_id: sessionId,
token_name: tokenName,
public_key: publicKeyHex,
}),
undefined,
).pipe(
Effect.fromResult,
Effect.map((url) => url.toString()),
);
if (!flags.noBrowser) {
yield* output.promptText("Press Enter to open browser and log in.", { defaultValue: "" });
yield* output.info(`Here is your login link in case browser did not open\n${loginUrl}`);
yield* Effect.ignore(browser.open(loginUrl));
} else {
yield* output.info(`Here is your login link, open it in the browser\n${loginUrl}`);
}
const verifyCode = Effect.gen(function* () {
const deviceCode = yield* output.promptText("Enter your verification code", {
validate: (v) => {
if (!v?.trim()) return "Verification code is required";
},
});
return yield* api
.fetchLoginSession(apiUrl, sessionId, deviceCode.trim())
.pipe(Effect.mapError((cause) => new LoginVerificationError({ cause })));
});
const verifyWithRetries = (
remainingRetries: number,
): Effect.Effect<LoginSessionResponse, LoginFailedError | NonInteractiveError> =>
verifyCode.pipe(
Effect.catchTag("LoginVerificationError", () =>
Effect.gen(function* () {
yield* output.error("Verification failed");
if (remainingRetries <= 0) {
return yield* Effect.fail(
new LoginFailedError({
detail: "Login failed after maximum retries",
suggestion: "Try running `supabase login` again",
}),
);
}
return yield* verifyWithRetries(remainingRetries - 1);
}),
),
);
const session = yield* verifyWithRetries(MAX_LOGIN_VERIFICATION_RETRIES);
const token = yield* crypto.decryptToken(ecdh, {
ciphertext: session.access_token,
publicKey: session.public_key,
nonce: session.nonce,
});
yield* validateToken(token);
const accessToken = Redacted.make(token);
yield* credentials.saveAccessToken(accessToken);
const distinctId = yield* resolveAuthenticatedDistinctId(accessToken);
yield* output.success(`Token ${tokenName} created successfully.`, {
command: "login",
tokenName,
});
yield* output.outro("You are now logged in. Happy coding!");
yield* captureLoginCompleted(
{
login_method: "browser_oauth",
token_name: tokenName,
},
distinctId,
);
});
// ---------------------------------------------------------------------------
// Main handler
// ---------------------------------------------------------------------------
export const login = Effect.fnUntraced(function* (flags: LoginFlags) {
const output = yield* Output;
yield* output.intro("Log in to Supabase");
const resolved = yield* resolveToken(flags.token);
if (Option.isSome(resolved)) {
return yield* saveDirectToken(resolved.value);
}
return yield* browserOAuthFlow(flags);
});