Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 79 additions & 2 deletions apps/server/src/provider/AntigravityAuth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,56 @@ it.layer(NodeServices.layer)("AntigravityAuth", (it) => {
}),
);

it.effect("shows Antigravity's sanitized sign-in rejection only to the owner", () =>
Effect.gen(function* () {
const harness = yield* makeHarness();
yield* harness.auth.controller.start(owner);
yield* phase(harness.auth, "waiting");
yield* Deferred.fail(
harness.authenticated,
new AcpErrors.AcpRequestError({
code: -32603,
errorMessage:
"Onboarding failed: User is ineligible for free-tier.\n Reason: Your current account is not eligible for Antigravity. Try signing in with another personal Google account.\u0007 ",
method: "authenticate",
}),
);
const failed = yield* phase(harness.auth, "failed");
assert.equal(
failed.message,
"Onboarding failed: User is ineligible for free-tier. Reason: Your current account is not eligible for Antigravity. Try signing in with another personal Google account.",
);
const other = yield* phase(harness.auth, "failed", otherOwner);
assert.equal(other.message, "Sign-in failed in another client.");
yield* Deferred.await(harness.closed);
}),
);

it.effect("keeps sensitive authenticate rejection details out of the sign-in status", () =>
Effect.gen(function* () {
for (const errorMessage of [
`Sign-in failed at ${callbackUrl}`,
"Redirect to http%3A%2F%2F127.0.0.1 failed",
"Rejected key sk-123456789012345678901234567890",
"Account person@example.com is not eligible",
"Onboarding failed. Visit support.google.com for help.",
"Onboarding failed: See https:example.com for help.",
"Authentication failed: token sk-live-abcd-efgh",
]) {
const harness = yield* makeHarness();
yield* harness.auth.controller.start(owner);
yield* phase(harness.auth, "waiting");
yield* Deferred.fail(
harness.authenticated,
new AcpErrors.AcpRequestError({ code: -32603, errorMessage, method: "authenticate" }),
);
const failed = yield* phase(harness.auth, "failed");
assert.equal(failed.message, "Google sign-in failed. Start sign-in again.", errorMessage);
yield* Deferred.await(harness.closed);
}
}),
);

it.effect("accepts direct local or cached completion without a callback RPC", () =>
Effect.gen(function* () {
const harness = yield* makeHarness({ interactive: false });
Expand All @@ -282,7 +332,7 @@ it.layer(NodeServices.layer)("AntigravityAuth", (it) => {
}),
);

it.effect("rejects mismatched callbacks without sending any HTTP request", () =>
it.effect("retains callback errors only for the owner until a valid callback is accepted", () =>
Effect.gen(function* () {
const harness = yield* makeHarness();
const state = yield* harness.auth.controller.start(owner);
Expand All @@ -297,9 +347,26 @@ it.layer(NodeServices.layer)("AntigravityAuth", (it) => {
.complete(owner, { flowId: state.flowId!, callbackUrl: invalidUrl })
.pipe(Effect.exit);
assert.isTrue(Exit.isFailure(result));
const waiting = yield* phase(harness.auth, "waiting");
assert.equal(
waiting.inputError,
"This redirect URL does not belong to the current sign-in.",
);
assert.equal(waiting.flowId, state.flowId);
assert.equal(waiting.authorizationUrl, authorizationUrl);
const other = yield* phase(harness.auth, "waiting", otherOwner);
assert.notExists(other.inputError);
assert.equal(other.message, "Sign-in is in progress in another client.");
}
assert.equal(harness.forwarded(), 0);
assert.equal((yield* phase(harness.auth, "waiting")).authorizationUrl, authorizationUrl);
const verifying = yield* harness.auth.controller.complete(owner, {
flowId: state.flowId!,
callbackUrl,
});
assert.equal(verifying.phase, "verifying");
assert.notExists(verifying.inputError);
assert.equal(harness.forwarded(), 1);
assert.notExists((yield* phase(harness.auth, "verifying")).inputError);
yield* harness.auth.controller.cancel(owner, state.flowId!);
}),
);
Expand Down Expand Up @@ -343,9 +410,19 @@ it.layer(NodeServices.layer)("AntigravityAuth", (it) => {
.cancel(otherOwner, state.flowId!)
.pipe(Effect.exit);
assert.isTrue(Exit.isFailure(wrongOwner));
const rejected = yield* harness.auth.controller
.complete(owner, { flowId: state.flowId!, callbackUrl: "not a URL" })
.pipe(Effect.exit);
assert.isTrue(Exit.isFailure(rejected));
assert.equal(
(yield* phase(harness.auth, "waiting")).inputError,
"Paste the complete redirect URL from the Google sign-in page.",
);
const cancelled = yield* harness.auth.controller.cancel(owner, state.flowId!);
assert.equal(cancelled.phase, "cancelled");
assert.isNull(cancelled.authorizationUrl);
assert.notExists(cancelled.inputError);
assert.notExists((yield* phase(harness.auth, "cancelled")).inputError);
assert.equal(harness.forwarded(), 0);
yield* Deferred.await(harness.closed);
assert.deepEqual(harness.catalog(), ["previous-account-model"]);
Expand Down
35 changes: 34 additions & 1 deletion apps/server/src/provider/AntigravityAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,10 +96,29 @@ function visibleSnapshot(snapshot: AuthSnapshot, ownerSessionId: string): Provid
authorizationUrl: null,
interaction: null,
expiresAt: null,
...(busy ? { message: "Sign-in is in progress in another client." } : {}),
inputError: null,
...(busy
? { message: "Sign-in is in progress in another client." }
: snapshot.state.phase === "failed"
? { message: "Sign-in failed in another client." }
: {}),
};
}

// Antigravity's authenticate rejection is the only text that says why Google turned the
// account down, but the same channel can echo callback URLs, tokens, paths, or emails.
function safeRejectionReason(message: string): string | undefined {
const text = message.replace(/[\p{Cc}\s]+/gu, " ").trim();
const plainProse =
text.length > 0 &&
text.length <= 300 &&
/\p{L}/u.test(text) &&
/^[\p{L}\p{N} .,;:!?'"()-]+$/u.test(text) &&
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
!/[\p{L}\p{N}][.:][\p{L}\p{N}]/u.test(text) &&
!/\S{20,}|\d{6,}|\bsk-[a-z0-9-]+/i.test(text);
return plainProse ? text : undefined;
}

function safeAuthFailure(cause: Cause.Cause<unknown>, usesBrowser: boolean): string {
const error = Cause.findErrorOption(cause);
if (Option.isSome(error)) {
Expand All @@ -119,6 +138,10 @@ function safeAuthFailure(cause: Cause.Cause<unknown>, usesBrowser: boolean): str
if (!usesBrowser && error.value.code === -32602) {
return "Antigravity rejected the configured credentials. Check the provider settings.";
}
if (error.value.method === "authenticate") {
const reason = safeRejectionReason(error.value.errorMessage);
if (reason) return reason;
}
}
}
return usesBrowser
Expand All @@ -143,6 +166,7 @@ export const makeAntigravityAuth = Effect.fn("makeAntigravityAuth")(function* <
authorizationUrl: null,
expiresAt: null,
message: null,
inputError: null,
};
const snapshot = yield* SubscriptionRef.make<AuthSnapshot>({
ownerSessionId: null,
Expand Down Expand Up @@ -226,6 +250,7 @@ export const makeAntigravityAuth = Effect.fn("makeAntigravityAuth")(function* <
phase: Exit.isSuccess(result) ? "succeeded" : "failed",
authorizationUrl: null,
expiresAt: null,
inputError: null,
message: Exit.isSuccess(result)
? usesBrowser
? "Signed in with Google."
Expand Down Expand Up @@ -276,6 +301,7 @@ export const makeAntigravityAuth = Effect.fn("makeAntigravityAuth")(function* <
...flow.state,
phase: "verifying",
authorizationUrl: null,
inputError: null,
message: "Checking Antigravity access and models.",
});
}),
Expand Down Expand Up @@ -306,6 +332,7 @@ export const makeAntigravityAuth = Effect.fn("makeAntigravityAuth")(function* <
phase,
authorizationUrl: null,
expiresAt: null,
inputError: null,
message,
});
return true;
Expand Down Expand Up @@ -398,12 +425,17 @@ export const makeAntigravityAuth = Effect.fn("makeAntigravityAuth")(function* <
options.instanceId,
flow.pending,
input.callbackUrl,
).pipe(
Effect.tapError((error) =>
publishFlow(flow, { ...flow.state, inputError: error.detail }),
),
);
flow.callbackSent = true;
yield* publishFlow(flow, {
...flow.state,
phase: "verifying",
authorizationUrl: null,
inputError: null,
message: "Waiting for Google to finish sign-in.",
});
const forwarding = yield* (
Expand Down Expand Up @@ -452,6 +484,7 @@ export const makeAntigravityAuth = Effect.fn("makeAntigravityAuth")(function* <
phase: "cancelled",
authorizationUrl: null,
expiresAt: null,
inputError: null,
message: "Google sign-in was cancelled by sign-out.",
});
}
Expand Down
2 changes: 2 additions & 0 deletions packages/contracts/src/providerSetup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,8 @@ export const ProviderAuthState = Schema.Struct({
authorizationUrl: Schema.NullOr(Schema.String),
expiresAt: Schema.NullOr(IsoDateTime),
message: Schema.NullOr(Schema.String),
// Callback validation feedback while the sign-in flow keeps waiting.
inputError: Schema.optionalKey(Schema.NullOr(Schema.String)),
// Newer servers may add method types, interactions, or owners; older
// clients drop what they cannot decode instead of rejecting the state.
methods: Schema.optionalKey(
Expand Down
Loading