Repository navigation
Expand file tree
/
Copy pathstepUp.ts
More file actions
213 lines (184 loc) · 7.8 KB
/
Copy pathstepUp.ts
File metadata and controls
213 lines (184 loc) · 7.8 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
import { isCancel, text } from "@clack/prompts";
import { TotpStatusSchema, TotpVerifyRequestSchema } from "@seamless-auth/types";
import kleur from "kleur";
import type { AuthClient } from "./authClient.js";
import { CancelledError } from "./cancel.js";
import type { ApiResponse } from "./http.js";
import type { TokenBundle } from "./keychain.js";
import { PermissionError } from "./permissionError.js";
import { isInteractive } from "./tty.js";
export const TOTP_CODE_ENV = "SEAMLESS_TOTP_CODE";
export const MAX_CODE_ATTEMPTS = 3;
export const STEP_UP_REQUIRED_MESSAGE = `This action needs a recent step-up: a code from your authenticator app, verified on this session within the last few minutes. Run the command in an interactive terminal to enter one, or set ${TOTP_CODE_ENV} to a current code.`;
export const NO_AUTHENTICATOR_MESSAGE =
"This action needs a step-up with an authenticator app, and your account has none enrolled. Enroll one from the admin dashboard or your app, then run this again, or make this change from the dashboard.";
// Raised for every step-up failure. It is a PermissionError so the commands that
// already report one print its message rather than a stack trace.
export class StepUpError extends PermissionError {
constructor(message: string) {
super(message);
this.name = "StepUpError";
}
}
/** How the client's current session started, and a way to swap it for a stepped-up one. */
export interface StepUpSession {
signIn(): TokenBundle["signIn"];
/** Stores the new session and ends the one it replaces. */
replace(next: TokenBundle): Promise<void>;
}
export type StepUpHandler = (client: AuthClient, session?: StepUpSession) => Promise<void>;
export interface AuthenticatorAppCopy {
requiredMessage?: string;
noAuthenticatorMessage?: string;
prompt?: string;
}
function errorCode(data: unknown): string | undefined {
if (data && typeof data === "object") {
const error = (data as Record<string, unknown>).error;
if (typeof error === "string") return error;
}
return undefined;
}
// A missing admin role is also a 403, so only the error code tells the two apart.
export function isStepUpRequired(res: Pick<ApiResponse, "status" | "data">): boolean {
return res.status === 403 && errorCode(res.data) === "step_up_required";
}
// A 401 that rejects a submitted code is an answer about the code, not about the
// access token, so it must not be treated as an expired session.
export function isFactorRejection(res: Pick<ApiResponse, "status" | "data">): boolean {
return res.status === 401 && errorCode(res.data) === "totp_verification_failed";
}
export function forbidden(res: Pick<ApiResponse, "status" | "data">): PermissionError {
return isStepUpRequired(res)
? new StepUpError(STEP_UP_REQUIRED_MESSAGE)
: new PermissionError();
}
function describeWait(seconds: number | undefined): string {
if (seconds === undefined || !Number.isFinite(seconds) || seconds <= 0) {
return "a few minutes";
}
if (seconds < 60) {
const s = Math.ceil(seconds);
return `${s} second${s === 1 ? "" : "s"}`;
}
const minutes = Math.ceil(seconds / 60);
return `${minutes} minute${minutes === 1 ? "" : "s"}`;
}
function retryAfterSeconds(res: ApiResponse<unknown>): number | undefined {
if (res.data && typeof res.data === "object") {
const value = (res.data as Record<string, unknown>).retryAfterSeconds;
if (typeof value === "number") return value;
}
const header = res.headers.get("retry-after");
return header === null || header.trim() === "" ? undefined : Number(header);
}
// Authenticator apps often show a code as "123 456".
function normalizeCode(raw: string): string {
return raw.replace(/\s+/g, "");
}
const codeShape = TotpVerifyRequestSchema.shape.code;
function isWellFormed(code: string): boolean {
return codeShape.safeParse(code).success;
}
async function requireEnrolledAuthenticator(
client: AuthClient,
noAuthenticatorMessage: string,
): Promise<void> {
const res = await client.get<unknown>("/totp/status");
// Only a definite "not enrolled" stops here. Anything else is left to the verify
// call, which reports its own failures.
if (!res.ok) return;
const parsed = TotpStatusSchema.loose().safeParse(res.data);
if (parsed.success && !parsed.data.enabled) {
throw new StepUpError(noAuthenticatorMessage);
}
}
/**
* Steps up the session with a code from the user's authenticator app, read from
* SEAMLESS_TOTP_CODE once and otherwise from a prompt.
*
* The environment code is used at most once per process. A code is single use, so
* a second step-up in the same run (a long migrate outlasting the window) needs a
* new one, which only a terminal can supply.
*/
export function authenticatorAppStepUp(
env: NodeJS.ProcessEnv = process.env,
copy: AuthenticatorAppCopy = {},
): StepUpHandler {
const requiredMessage = copy.requiredMessage ?? STEP_UP_REQUIRED_MESSAGE;
const noAuthenticatorMessage = copy.noAuthenticatorMessage ?? NO_AUTHENTICATOR_MESSAGE;
const prompt = copy.prompt ?? "This action needs a fresh step-up on your session.";
let envCode = env[TOTP_CODE_ENV]?.trim() || undefined;
const nextCode = async (
attempt: number,
): Promise<{ code: string; fromEnv: boolean }> => {
if (envCode !== undefined) {
const code = normalizeCode(envCode);
envCode = undefined;
if (!isWellFormed(code)) {
throw new StepUpError(
`${TOTP_CODE_ENV} is not a code from an authenticator app. Set it to the 6 digits the app shows.`,
);
}
return { code, fromEnv: true };
}
if (!isInteractive()) throw new StepUpError(requiredMessage);
if (attempt === 1) {
process.stderr.write(
`${kleur.yellow(prompt)}\n`,
);
}
const answer = await text({
message:
attempt === 1
? "Enter the code from your authenticator app"
: "Enter a new code from your authenticator app",
placeholder: "123456",
validate: (value) =>
isWellFormed(normalizeCode(value ?? ""))
? undefined
: "Enter the 6 digits your authenticator app shows",
// Keeps stdout clean for commands printing --json.
output: process.stderr,
});
if (isCancel(answer)) throw new CancelledError();
return { code: normalizeCode(answer), fromEnv: false };
};
return async (client) => {
await requireEnrolledAuthenticator(client, noAuthenticatorMessage);
for (let attempt = 1; ; attempt++) {
const { code, fromEnv } = await nextCode(attempt);
const res = await client.post<unknown>("/totp/verify-mfa", { code });
if (res.ok) return;
if (isFactorRejection(res)) {
if (attempt >= MAX_CODE_ATTEMPTS) {
throw new StepUpError(
`None of the ${MAX_CODE_ATTEMPTS} codes was accepted. Check that your device clock is correct and that the code is from this instance's entry in your authenticator app, then run the command again.`,
);
}
if (fromEnv && !isInteractive()) {
throw new StepUpError(
`The code in ${TOTP_CODE_ENV} was not accepted. It may have expired or already been used; set a current one and run the command again.`,
);
}
process.stderr.write(
`${kleur.red("That code was not accepted. Codes are single use and change every 30 seconds.")}\n`,
);
continue;
}
if (res.status === 423) {
throw new StepUpError(
`Your account is locked after too many failed attempts. Try again in ${describeWait(retryAfterSeconds(res))}.`,
);
}
if (res.status === 429) {
throw new StepUpError(
`Too many code attempts. Wait ${describeWait(retryAfterSeconds(res))} and run the command again.`,
);
}
throw new StepUpError(
`Could not verify the authenticator app code (${res.status}).`,
);
}
};
}