Skip to content
Merged
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
3 changes: 3 additions & 0 deletions features/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Verification paths

- [OAuth login](oauth-login.md): browser consent, Google sign-in, and device authorization.
60 changes: 60 additions & 0 deletions features/oauth-login.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 3 additions & 5 deletions src/auth/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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',
Expand All @@ -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',
Expand Down Expand Up @@ -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(/=+$/, '');
Expand Down
12 changes: 12 additions & 0 deletions test/onboarding-run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', {
Expand Down
Loading