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
15 changes: 10 additions & 5 deletions .claude/skills/wizard-auth/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Handles OAuth2 PKCE authentication with Confidence via Auth0.
2. **Prompt user** — If valid token exists, offer to reuse or re-authenticate. If no token, ask whether to create a new account or sign in.
3. **Browser-based OAuth2 PKCE** — Start local HTTP server on port 8084, open browser to Auth0 authorize endpoint, wait for callback with authorization code.
4. **Token exchange** — Exchange authorization code + PKCE verifier for access token and refresh token.
5. **Persist tokens** — Write access token to `$TMPDIR/confidence_token`, refresh token to `$TMPDIR/confidence_refresh_token`.
5. **Persist tokens** — Write access token to `$TMPDIR/confidence_token`, refresh token to `$TMPDIR/confidence_refresh_token`, and the Auth0 organization (`org_id` claim, falling back to `https://confidence.dev/org_login_id`) to `$TMPDIR/confidence_organization`.
6. **Extract region** — Decode JWT payload, read `https://confidence.dev/region` claim (EU or US) to determine regional API endpoints.

## Auth0 Configuration
Expand Down Expand Up @@ -44,7 +44,12 @@ The auth flow is implemented in `src/lib/auth.ts` using Node.js built-ins:

## Token Files

| File | Content |
| ---------------------------------- | -------------------------------- |
| `$TMPDIR/confidence_token` | JWT access token |
| `$TMPDIR/confidence_refresh_token` | Refresh token for silent re-auth |
| File | Content |
| ---------------------------------- | ------------------------------------------------------------- |
| `$TMPDIR/confidence_token` | JWT access token |
| `$TMPDIR/confidence_refresh_token` | Refresh token for silent re-auth |
| `$TMPDIR/confidence_organization` | Auth0 organization for skipping the workspace prompt on login |

## Remembered Workspace

On interactive **login** (never signup), the remembered organization is passed as the `organization` parameter to the Auth0 authorize endpoint so the workspace prompt is skipped. The `CONFIDENCE_ORGANIZATION` env var overrides the remembered value. If Auth0 returns an error on the callback while an organization was passed, the flow retries once without the `organization` parameter instead of failing.
151 changes: 151 additions & 0 deletions __tests__/lib/auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { execFile } from 'node:child_process';
import { http, HttpResponse, passthrough } from 'msw';
import { server } from '../msw/server.js';
import { buildTestJwt, prepareAuthTokens } from '../shared/auth/index.js';
import { authenticate, AUTH_CALLBACK_PORT } from '@lib/auth.js';

// Isolate the token files in a dedicated temp directory so the real
// logins performed here don't leak tokens into test files that run in
// parallel workers and read the shared tmpdir. Must run before module
// imports because token paths are resolved at module load.
await vi.hoisted(async () => {
const { mkdtempSync } = await import('node:fs');
const { tmpdir } = await import('node:os');
const { join } = await import('node:path');
process.env['TMPDIR'] = mkdtempSync(join(tmpdir(), 'confidence-auth-test-'));
});

// Mocking the browser opener because it would launch a real browser.
vi.mock('node:child_process', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:child_process')>();
return { ...actual, execFile: vi.fn() };
});

const CALLBACK_URL = `http://localhost:${AUTH_CALLBACK_PORT}/callback`;

beforeEach(() => {
server.use(http.get(CALLBACK_URL, () => passthrough()));
});

afterEach(() => {
vi.unstubAllEnvs();
});

function useTokenHandler(claims: Record<string, unknown>) {
server.use(
http.post('https://auth.confidence.dev/oauth/token', () =>
HttpResponse.json({
access_token: buildTestJwt(claims),
refresh_token: 'test-refresh-token',
token_type: 'Bearer',
expires_in: 86400,
}),
),
);
}

async function waitForOpenedUrl(): Promise<string> {
const openedBrowsers = vi.mocked(execFile).mock.calls.length;
await vi.waitFor(() =>
expect(vi.mocked(execFile).mock.calls.length).toBeGreaterThan(openedBrowsers),
);
const [, args] = vi.mocked(execFile).mock.calls.at(-1) as unknown as [string, string[]];
return args.find((arg) => arg.startsWith('http'))!;
}

async function hitCallback(query: string, init?: RequestInit): Promise<Response> {
return vi.waitFor(() => fetch(`${CALLBACK_URL}${query}`, init));
}

async function completeAuth(mode: 'signup' | 'login'): Promise<string> {
const result = authenticate(mode);
const authUrl = await waitForOpenedUrl();
await hitCallback('?code=test-code');
await result;
return authUrl;
}

describe('authenticate', () => {
describe('when no workspace is remembered', () => {
it('opens the authorize URL without an organization parameter', async () => {
using _auth = prepareAuthTokens('none');
useTokenHandler({ org_id: 'org_123' });
const sut = completeAuth;

const authUrl = await sut('login');

expect(authUrl).not.toContain('organization=');
});
});

describe('when a previous login succeeded', () => {
it('remembers the org_id claim and passes it as organization on the next login', async () => {
using _auth = prepareAuthTokens('none');
useTokenHandler({ org_id: 'org_123' });
const sut = completeAuth;

await sut('login');
const authUrl = await sut('login');

expect(authUrl).toContain('organization=org_123');
});

it('falls back to the org_login_id claim when org_id is absent', async () => {
using _auth = prepareAuthTokens('none');
useTokenHandler({ 'https://confidence.dev/org_login_id': 'acme' });
const sut = completeAuth;

await sut('login');
const authUrl = await sut('login');

expect(authUrl).toContain('organization=acme');
});

it('prefers the CONFIDENCE_ORGANIZATION env var over the remembered workspace', async () => {
using _auth = prepareAuthTokens('none');
useTokenHandler({ org_id: 'org_remembered' });
const sut = completeAuth;

await sut('login');
vi.stubEnv('CONFIDENCE_ORGANIZATION', 'org_override');
const authUrl = await sut('login');

expect(authUrl).toContain('organization=org_override');
});

it('never passes an organization in signup mode', async () => {
using _auth = prepareAuthTokens('none');
useTokenHandler({ org_id: 'org_123' });
const sut = completeAuth;

await sut('login');
const authUrl = await sut('signup');

expect(authUrl).not.toContain('organization=');
});
});

describe('when Auth0 rejects a login with a remembered workspace', () => {
it('retries without the organization parameter instead of failing', async () => {
// Arrange
using _auth = prepareAuthTokens('none');
useTokenHandler({ org_id: 'org_123' });
const sut = completeAuth;
await sut('login');

// Act
const result = authenticate('login');
const authUrl = await waitForOpenedUrl();
const response = await hitCallback('?error=invalid_request', { redirect: 'manual' });
const retryUrl = response.headers.get('location')!;
await hitCallback('?code=test-code');
await result;

// Assert
expect(authUrl).toContain('organization=org_123');
expect(response.status).toBe(302);
expect(retryUrl).toContain('/authorize?');
expect(retryUrl).not.toContain('organization=');
});
});
});
7 changes: 7 additions & 0 deletions __tests__/shared/auth/tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { TokenType } from './types.js';

const TOKEN_PATH = join(tmpdir(), 'confidence_token');
const REFRESH_TOKEN_PATH = join(tmpdir(), 'confidence_refresh_token');
const ORGANIZATION_PATH = join(tmpdir(), 'confidence_organization');

const TOKEN_CONFIG = { encoding: 'utf-8', mode: 0o600 } as const;
const DEFAULT_EMAIL = 'existing@example.com';
Expand Down Expand Up @@ -59,6 +60,12 @@ function clearAuthTokens(): void {
} catch {
// File may not exist, ignore.
}

try {
unlinkSync(ORGANIZATION_PATH);
} catch {
// File may not exist, ignore.
}
}

function writeToken(token: string): void {
Expand Down
3 changes: 2 additions & 1 deletion scripts/clean-dev-env.sh
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,9 @@ removed=0
if $clean_auth; then
token_file="${TMPDIR:-/tmp}/confidence_token"
refresh_file="${TMPDIR:-/tmp}/confidence_refresh_token"
org_file="${TMPDIR:-/tmp}/confidence_organization"

for f in "$token_file" "$refresh_file"; do
for f in "$token_file" "$refresh_file" "$org_file"; do
if [[ -f "$f" ]]; then
rm "$f"
echo "Removed $f"
Expand Down
51 changes: 46 additions & 5 deletions src/lib/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export const AUTH_CALLBACK_PORT = 8084;

const TOKEN_FILE = join(tmpdir(), 'confidence_token');
const REFRESH_TOKEN_FILE = join(tmpdir(), 'confidence_refresh_token');
const ORGANIZATION_FILE = join(tmpdir(), 'confidence_organization');

function base64url(buf: Buffer): string {
return buf.toString('base64url');
Expand Down Expand Up @@ -50,12 +51,24 @@ function extractRegion(token: string): 'EU' | 'US' {
return region === 'US' ? 'US' : 'EU';
}

function extractOrganization(token: string): string | undefined {
const payload = decodeJwtPayload(token);
const organization =
(payload.org_id as string | undefined) ??
(payload['https://confidence.dev/org_login_id'] as string | undefined);
return organization ?? undefined;
}

function persistTokens(accessToken: string, refreshToken?: string): void {
const config = { encoding: 'utf-8', mode: 0o600 } as const;
writeFileSync(TOKEN_FILE, accessToken, config);
if (refreshToken) {
writeFileSync(REFRESH_TOKEN_FILE, refreshToken, config);
}
const organization = extractOrganization(accessToken);
if (organization) {
writeFileSync(ORGANIZATION_FILE, organization, config);
}
}

export function loadPersistedToken(): string | null {
Expand All @@ -76,6 +89,17 @@ function loadPersistedRefreshToken(): string | null {
}
}

function resolveOrganization(): string | undefined {
const override = env('CONFIDENCE_ORGANIZATION');
if (override) return override;
if (!existsSync(ORGANIZATION_FILE)) return undefined;
try {
return readFileSync(ORGANIZATION_FILE, 'utf-8').trim() || undefined;
} catch {
return undefined;
}
}

export function validateToken(token: string): {
valid: boolean;
region?: 'EU' | 'US';
Expand Down Expand Up @@ -143,6 +167,7 @@ export async function refreshAccessToken(): Promise<AuthResult> {

export function authenticate(mode: 'signup' | 'login', signal?: AbortSignal): Promise<AuthResult> {
const clientId = mode === 'signup' ? AUTH_CLIENT_ID_SIGNUP : AUTH_CLIENT_ID_LOGIN;
const organization = mode === 'login' ? resolveOrganization() : undefined;
const { verifier, challenge } = generatePKCE();
const redirectUri = `http://localhost:${AUTH_CALLBACK_PORT}/callback`;

Expand All @@ -152,6 +177,8 @@ export function authenticate(mode: 'signup' | 'login', signal?: AbortSignal): Pr
return;
}

let retriedWithoutOrganization = false;

const server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
const url = new URL(req.url ?? '/', `http://localhost:${AUTH_CALLBACK_PORT}`);

Expand All @@ -165,6 +192,12 @@ export function authenticate(mode: 'signup' | 'login', signal?: AbortSignal): Pr
const error = url.searchParams.get('error');

if (error || !code) {
if (organization && !retriedWithoutOrganization) {
retriedWithoutOrganization = true;
res.writeHead(302, { Location: buildAuthUrl({ clientId, challenge, redirectUri }) });
res.end();
return;
}
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(errorPage);
server.close();
Expand Down Expand Up @@ -194,7 +227,7 @@ export function authenticate(mode: 'signup' | 'login', signal?: AbortSignal): Pr
});

server.listen(AUTH_CALLBACK_PORT, () => {
const authUrl = buildAuthUrl(clientId, challenge, redirectUri);
const authUrl = buildAuthUrl({ clientId, challenge, redirectUri, organization });
openBrowser(authUrl);
});

Expand All @@ -217,16 +250,24 @@ export function authenticate(mode: 'signup' | 'login', signal?: AbortSignal): Pr
});
}

function buildAuthUrl(clientId: string, challenge: string, redirectUri: string): string {
function buildAuthUrl(opts: {
clientId: string;
challenge: string;
redirectUri: string;
organization?: string;
}): string {
const params = new URLSearchParams({
response_type: 'code',
client_id: clientId,
redirect_uri: redirectUri,
client_id: opts.clientId,
redirect_uri: opts.redirectUri,
scope: AUTH_SCOPE,
audience: AUTH_AUDIENCE,
code_challenge: challenge,
code_challenge: opts.challenge,
code_challenge_method: 'S256',
});
if (opts.organization) {
params.set('organization', opts.organization);
}
return `${AUTH_BASE_URL}/authorize?${params.toString()}`;
}

Expand Down
1 change: 1 addition & 0 deletions src/lib/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ type EnvKey =
| 'CONFIDENCE_TELEMETRY'
| 'CONFIDENCE_AUTH_DOMAIN'
| 'CONFIDENCE_AUTH_URL'
| 'CONFIDENCE_ORGANIZATION'
| 'CONFIDENCE_SKILLS_URL'
| 'CONFIDENCE_MCP_URL'
| 'CONFIDENCE_TELEMETRY_KEY_URL'
Expand Down