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..607dd81 --- /dev/null +++ b/features/oauth-login.md @@ -0,0 +1,60 @@ +# 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. This attempts access-token revocation +and removes local credentials; it does not revoke the refresh token. + +## 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/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(/=+$/, ''); diff --git a/test/onboarding-run.test.ts b/test/onboarding-run.test.ts index 3a5c020..8606962 100644 --- a/test/onboarding-run.test.ts +++ b/test/onboarding-run.test.ts @@ -167,6 +167,18 @@ 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(' '); + assert.ok(scopes.includes('threads:read')); + 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', {