From 9e3418f5b07e7e8c9a3915844446ba814c2f6e48 Mon Sep 17 00:00:00 2001 From: Justin Helmer Date: Thu, 8 Oct 2026 07:48:59 -0700 Subject: [PATCH 1/4] fix(auth): stop requesting retired dataset scopes --- src/auth/oauth.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/auth/oauth.ts b/src/auth/oauth.ts index 6be63ef..e004411 100644 --- a/src/auth/oauth.ts +++ b/src/auth/oauth.ts @@ -5,6 +5,7 @@ import { openBrowser } from '../utils/browser'; import { readInstallRef } from '../telemetry/environment'; import { ONBOARDING_RUN_QUERY_PARAM, resolveOnboardingRunId, withOnboardingRun } from './onboarding-run'; import type { OAuthTokenResponse, OIDCConfig } from './types'; +import type { OAuthClient } from '../generated/types'; import { CLIError } from '../errors/base'; import { ExitCode } from '../errors/codes'; import { ALERT_ICON, CHECK_ICON, renderBrowserPage } from '../utils/browser-page'; @@ -37,7 +38,7 @@ export function oauthClientSecret(): string { } // Full set of permission scopes requested by the CLI. -export const DEFAULT_SCOPES = [ +export const DEFAULT_SCOPES = ([ 'agent_tools:read', 'agent_tools:write', 'analytics:export', @@ -58,9 +59,6 @@ export const DEFAULT_SCOPES = [ 'cloud_infra:delete', 'cloud_infra:read', 'cloud_infra:write', - 'datasets:delete', - 'datasets:read', - 'datasets:write', 'integrations:delete', 'integrations:read', 'integrations:write', @@ -102,7 +100,7 @@ export const DEFAULT_SCOPES = [ 'workspaces:delete', 'workspaces:read', 'workspaces:write', -].join(' '); +] satisfies OAuthClient['scopes']).join(' '); function base64UrlEncode(buf: Buffer): string { return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); From b337bd3f4a319731b910f94b848dd61b4dd33571 Mon Sep 17 00:00:00 2001 From: Justin Helmer Date: Thu, 8 Oct 2026 07:58:54 -0700 Subject: [PATCH 2/4] test(auth): verify Google consent omits retired scopes --- features/README.md | 3 ++ features/oauth-login.md | 59 +++++++++++++++++++++++++++++++++++++ test/onboarding-run.test.ts | 11 +++++++ 3 files changed, 73 insertions(+) create mode 100644 features/README.md create mode 100644 features/oauth-login.md diff --git a/features/README.md b/features/README.md new file mode 100644 index 0000000..7683a5c --- /dev/null +++ b/features/README.md @@ -0,0 +1,3 @@ +# Verification paths + +- [OAuth login](oauth-login.md): browser consent, Google sign-in, and device authorization. diff --git a/features/oauth-login.md b/features/oauth-login.md new file mode 100644 index 0000000..cb4a554 --- /dev/null +++ b/features/oauth-login.md @@ -0,0 +1,59 @@ +# OAuth login + +## User outcome + +A user signs in, approves the registered CLI permissions, and receives an OAuth +grant. Google sign-in carries the same consent request through the provider +redirect. Retired scopes are absent from both browser and device requests. + +## Entry points and prerequisites + +`polylane auth login` uses browser consent. `polylane auth login --no-browser` +uses device authorization. The interactive login offers Google sign-in. +Use a released binary with its registered OAuth client, or a local build with +`POLYLANE_OAUTH_CLIENT_ID` and `POLYLANE_OAUTH_CLIENT_SECRET` from the target +environment's secret store. The user needs a verified account and workspace +membership. Use a test account and a separate operating-system user for manual +checks: the CLI stores credentials under that user's `~/.polylane` directory. + +## Local checks + +From the CLI repository, run `npm ci`, `npm run typecheck`, `npm run lint`, +`npm run test`, and `npm run build`. Typecheck regenerates the live API types. +The default scope list must satisfy `OAuthClient.scopes`; a retired scope +must cause a type error. + +`test/onboarding-run.test.ts` checks the Google redirect without a browser or +network request. It requires that the nested consent URL omit all three +`datasets:*` scopes. `test/oauth-client.test.ts` preserves the issue, agent +tool, and page permissions. The type error and Google regression both fail +when the retired dataset scopes are restored. + +## Manual sign-in + +Check `polylane --version` and `polylane auth status` before using the released +build. Start browser login under the test operating-system user, select Google, finish +sign-in, and approve consent. Expect the CLI to finish sign-in without +`Invalid scopes requested`. Confirm `polylane auth status` and +`polylane workspace list`. Repeat with `--no-browser` to check device consent. +Confirm the grant's scopes stay within the registered client allowlist. + +Use an account you own. Keep credentials out of proof logs and run +`polylane auth logout` after the check to revoke and remove the test grant. + +## Boundaries and proof status + +The server rejects a scope outside the registered client allowlist. Keep that +refusal; removing a product permission does not permit restoring it for login. +The offline checks prove request construction and the generated API contract. +They do not prove Google acceptance, the token exchange, or a customer login. +Live browser and device sign-in on a released build remain a manual check. +Record the tested revision, build, environment, result, and sanitized evidence +in the pull request before claiming that user outcome passed. + +## Source anchors + +- `src/auth/oauth.ts`: requested scopes, browser URLs, and device requests. +- `src/commands/auth/login.ts`: login entry point and coordination. +- `src/auth/credentials.ts`: credential persistence. +- `src/generated/types.ts`: generated OAuth client scope contract. diff --git a/test/onboarding-run.test.ts b/test/onboarding-run.test.ts index 3a5c020..5523f08 100644 --- a/test/onboarding-run.test.ts +++ b/test/onboarding-run.test.ts @@ -167,6 +167,17 @@ describe('buildBrowserFlowUrls', () => { assert.ok(tagged.timeoutMs > plain.timeoutMs); }); + it('omits retired dataset scopes from the Google consent redirect', () => { + const { openUrl } = buildBrowserFlowUrls(mockConfig(), 'state123', 'challenge123', { + provider: 'google', + }); + const consentUrl = new URL(openUrl.searchParams.get('redirect')!, openUrl); + const scopes = consentUrl.searchParams.get('scope')!.split(' '); + for (const scope of ['datasets:read', 'datasets:write', 'datasets:delete']) { + assert.ok(!scopes.includes(scope), `${scope} is retired`); + } + }); + it('keeps the signup entry shape when no provider is named', () => { const plain = buildBrowserFlowUrls(mockConfig(), 'state123', 'challenge123'); const signup = buildBrowserFlowUrls(mockConfig(), 'state123', 'challenge123', { From 40437e3ac0e0e5bb7d40d36a69c05bcf677b9878 Mon Sep 17 00:00:00 2001 From: Justin Helmer Date: Thu, 8 Oct 2026 08:01:56 -0700 Subject: [PATCH 3/4] docs(auth): state login check cleanup precisely --- features/oauth-login.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/features/oauth-login.md b/features/oauth-login.md index cb4a554..607dd81 100644 --- a/features/oauth-login.md +++ b/features/oauth-login.md @@ -39,7 +39,8 @@ sign-in, and approve consent. Expect the CLI to finish sign-in without Confirm the grant's scopes stay within the registered client allowlist. Use an account you own. Keep credentials out of proof logs and run -`polylane auth logout` after the check to revoke and remove the test grant. +`polylane auth logout` after the check. This attempts access-token revocation +and removes local credentials; it does not revoke the refresh token. ## Boundaries and proof status From 6b5fcb2a3dd7e3dba02faa5055fccadef13c5fa0 Mon Sep 17 00:00:00 2001 From: Justin Helmer Date: Thu, 8 Oct 2026 08:09:35 -0700 Subject: [PATCH 4/4] test(auth): retain a valid permission in Google consent --- test/onboarding-run.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/onboarding-run.test.ts b/test/onboarding-run.test.ts index 5523f08..8606962 100644 --- a/test/onboarding-run.test.ts +++ b/test/onboarding-run.test.ts @@ -173,6 +173,7 @@ describe('buildBrowserFlowUrls', () => { }); const consentUrl = new URL(openUrl.searchParams.get('redirect')!, openUrl); const scopes = consentUrl.searchParams.get('scope')!.split(' '); + assert.ok(scopes.includes('threads:read')); for (const scope of ['datasets:read', 'datasets:write', 'datasets:delete']) { assert.ok(!scopes.includes(scope), `${scope} is retired`); }