Skip to content

Commit f99e419

Browse files
committed
Fix auth and startup issues: pairing URL path, secret store error handling, TOCTOU race, duplicate tokens
- Set pathname to /pair in issueStartupPairingUrl so token survives routing - Use Effect.flatMap + Effect.fail instead of Effect.map in secret store set error path - Use Ref.modify for atomic read-and-consume in bootstrap credential service - Reuse resolveStartupBrowserTarget result for both logging and browser open
1 parent a11cb7e commit f99e419

File tree

4 files changed

+68
-38
lines changed

4 files changed

+68
-38
lines changed

apps/server/src/auth/Layers/BootstrapCredentialService.ts

Lines changed: 48 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -49,47 +49,60 @@ export const makeBootstrapCredentialService = Effect.gen(function* () {
4949
return credential;
5050
});
5151

52+
type ConsumeResult =
53+
| { readonly _tag: "error"; readonly error: BootstrapCredentialError }
54+
| { readonly _tag: "ok"; readonly grant: BootstrapGrant };
55+
5256
const consume: BootstrapCredentialServiceShape["consume"] = (credential) =>
5357
Effect.gen(function* () {
54-
const current = yield* Ref.get(grantsRef);
55-
const grant = current.get(credential);
56-
if (!grant) {
57-
return yield* new BootstrapCredentialError({
58-
message: "Unknown bootstrap credential.",
59-
});
60-
}
61-
62-
if (DateTime.isGreaterThanOrEqualTo(yield* DateTime.now, grant.expiresAt)) {
63-
yield* Ref.update(grantsRef, (state) => {
64-
const next = new Map(state);
65-
next.delete(credential);
66-
return next;
67-
});
68-
return yield* new BootstrapCredentialError({
69-
message: "Bootstrap credential expired.",
70-
});
71-
}
58+
const now = yield* DateTime.now;
59+
const result = yield* Ref.modify(
60+
grantsRef,
61+
(current): readonly [ConsumeResult, Map<string, StoredBootstrapGrant>] => {
62+
const grant = current.get(credential);
63+
if (!grant) {
64+
return [
65+
{
66+
_tag: "error",
67+
error: new BootstrapCredentialError({ message: "Unknown bootstrap credential." }),
68+
},
69+
current,
70+
];
71+
}
7272

73-
const remainingUses = grant.remainingUses;
74-
if (typeof remainingUses === "number") {
75-
yield* Ref.update(grantsRef, (state) => {
76-
const next = new Map(state);
77-
if (remainingUses <= 1) {
73+
if (DateTime.isGreaterThanOrEqualTo(now, grant.expiresAt)) {
74+
const next = new Map(current);
7875
next.delete(credential);
79-
} else {
80-
next.set(credential, {
81-
...grant,
82-
remainingUses: remainingUses - 1,
83-
});
76+
return [
77+
{
78+
_tag: "error",
79+
error: new BootstrapCredentialError({ message: "Bootstrap credential expired." }),
80+
},
81+
next,
82+
];
83+
}
84+
85+
const next = new Map(current);
86+
const remainingUses = grant.remainingUses;
87+
if (typeof remainingUses === "number") {
88+
if (remainingUses <= 1) {
89+
next.delete(credential);
90+
} else {
91+
next.set(credential, { ...grant, remainingUses: remainingUses - 1 });
92+
}
8493
}
85-
return next;
86-
});
87-
}
8894

89-
return {
90-
method: grant.method,
91-
expiresAt: grant.expiresAt,
92-
} satisfies BootstrapGrant;
95+
return [
96+
{ _tag: "ok", grant: { method: grant.method, expiresAt: grant.expiresAt } },
97+
next,
98+
];
99+
},
100+
);
101+
102+
if (result._tag === "error") {
103+
return yield* result.error;
104+
}
105+
return result.grant;
93106
});
94107

95108
return {

apps/server/src/auth/Layers/ServerAuth.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ export const makeServerAuth = Effect.gen(function* () {
124124
bootstrapCredentials.issueOneTimeToken().pipe(
125125
Effect.map((credential) => {
126126
const url = new URL(baseUrl);
127+
url.pathname = "/pair";
127128
url.searchParams.set("token", credential);
128129
return url.toString();
129130
}),

apps/server/src/auth/Layers/ServerSecretStore.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,10 @@ export const makeServerSecretStore = Effect.gen(function* () {
4747
Effect.catch((cause) =>
4848
fileSystem.remove(tempPath).pipe(
4949
Effect.orElseSucceed(() => undefined),
50-
Effect.map(
51-
() => new SecretStoreError({ message: `Failed to persist secret ${name}.`, cause }),
50+
Effect.flatMap(() =>
51+
Effect.fail(
52+
new SecretStoreError({ message: `Failed to persist secret ${name}.`, cause }),
53+
),
5254
),
5355
),
5456
),

apps/server/src/serverRuntimeStartup.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -388,8 +388,22 @@ const makeServerRuntimeStartup = Effect.gen(function* () {
388388
yield* Effect.logInfo("Authentication required. Open T3 Code using the pairing URL.", {
389389
pairingUrl,
390390
});
391+
if (!serverConfig.noBrowser) {
392+
const { openBrowser } = yield* Open;
393+
yield* runStartupPhase(
394+
"browser.open",
395+
openBrowser(pairingUrl).pipe(
396+
Effect.catch(() =>
397+
Effect.logInfo("browser auto-open unavailable", {
398+
hint: `Open ${pairingUrl} in your browser.`,
399+
}),
400+
),
401+
),
402+
);
403+
}
404+
} else {
405+
yield* runStartupPhase("browser.open", maybeOpenBrowser);
391406
}
392-
yield* runStartupPhase("browser.open", maybeOpenBrowser);
393407
yield* Effect.logDebug("startup phase: complete");
394408
}),
395409
);

0 commit comments

Comments
 (0)