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
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { BrainCircuit, User } from 'lucide-react';
import { BrainCircuit, Key, User } from 'lucide-react';

interface Props {
scopes: string[];
Expand Down Expand Up @@ -38,4 +38,8 @@ const scopeData = {
name: 'Connect your user profile',
icon: User,
},
'api_key:create': {
name: 'Create API keys',
icon: Key,
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { getApp } from '@/services/db/apps/get';
import { createAppMembership } from '@/services/db/apps/membership';

import { issueOAuthToken } from '@/services/db/auth/oauth-token';
import { createApiKey } from '@/services/db/api-keys';

import type { TokenMetadata } from '@/types/token-metadata';

Expand Down Expand Up @@ -178,13 +179,58 @@ export async function handleIssueToken(
}
}

/* 🔟 Check if api_key:create scope is present */
const scopes = scope.split(' ');
const shouldCreateApiKey = scopes.includes('api_key:create');

const { session, refreshToken } = await issueOAuthToken({
userId: user.id,
appId: app.id,
scope,
metadata,
});

if (shouldCreateApiKey) {
logger.emit({
severityText: 'INFO',
body: 'Creating API key for user with api_key:create scope',
attributes: {
userId: user.id,
echoAppId: app.id,
function: 'handleInitialTokenIssuance',
},
});

/* Generate an API key instead of a temporary JWT token */
const apiKey = await createApiKey(user.id, {
echoAppId: app.id,
name: 'OAuth Generated API Key',
});

logger.emit({
severityText: 'INFO',
body: 'API key generated for OAuth flow',
attributes: {
userId: user.id,
echoAppId: app.id,
apiKeyId: apiKey.id,
function: 'handleInitialTokenIssuance',
},
});

/* Return API key as access token with a very long expiration (100 years) */
return tokenResponse({
accessToken: {
access_token: apiKey.key,
scope,
access_token_expiry: new Date(
Date.now() + 100 * 365 * 24 * 60 * 60 * 1000
), // 100 years
},
refreshToken,
});
}

const accessToken = await createEchoAccessJwt({
user_id: user.id,
app_id: app.id,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import { describe, test, expect, beforeAll } from 'vitest';
import {
TEST_CLIENT_IDS,
TEST_USER_IDS,
generateCodeVerifier,
generateCodeChallenge,
generateState,
echoControlApi,
TEST_CONFIG,
} from '../../utils/index.js';

describe('OAuth API Key Creation with api_key:create Scope', () => {
beforeAll(async () => {
// Verify test environment
expect(TEST_CONFIG.services.echoControl).toBeTruthy();
expect(process.env.INTEGRATION_TEST_JWT).toBeTruthy();
});

describe('Token Exchange with api_key:create scope', () => {
test('should return an API key as access token when api_key:create scope is requested', async () => {
const codeVerifier = generateCodeVerifier();
const codeChallenge = generateCodeChallenge(codeVerifier);
const state = generateState();

console.log('Getting authorization code with api_key:create scope...');

const redirectUrl = await echoControlApi.validateOAuthAuthorizeRequest({
client_id: TEST_CLIENT_IDS.primary,
redirect_uri: 'http://localhost:3000/callback',
state,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
scope: 'llm:invoke offline_access api_key:create',
prompt: 'none', // Skip consent page for automated testing
});

// Extract authorization code from callback URL
const callbackUrl = new URL(redirectUrl);
const authCode = callbackUrl.searchParams.get('code');
const returnedState = callbackUrl.searchParams.get('state');

expect(authCode).toBeTruthy();
expect(returnedState).toBe(state);

console.log('✅ Got authorization code with api_key:create scope');

// Exchange authorization code for tokens
console.log('Exchanging auth code for API key...');

const tokenResponse = await echoControlApi.exchangeCodeForToken({
code: authCode!,
client_id: TEST_CLIENT_IDS.primary,
redirect_uri: 'http://localhost:3000/callback',
code_verifier: codeVerifier,
});

console.log('Token response:', {
access_token_preview:
tokenResponse.access_token.substring(0, 50) + '...',
token_type: tokenResponse.token_type,
expires_in: tokenResponse.expires_in,
scope: tokenResponse.scope,
has_refresh_token: !!tokenResponse.refresh_token,
});

// Verify token response structure
expect(tokenResponse.access_token).toBeTruthy();
expect(tokenResponse.token_type).toBe('Bearer');
expect(tokenResponse.scope).toContain('api_key:create');
expect(tokenResponse.refresh_token).toBeTruthy();

// Verify the access token is an API key (not a JWT)
// API keys should NOT have JWT structure (3 parts separated by dots)
const accessTokenParts = tokenResponse.access_token.split('.');
expect(accessTokenParts.length).not.toBe(3); // Not a JWT

// API keys should start with the API key prefix (typically 'echo_')
expect(tokenResponse.access_token).toMatch(/^echo_/);

// Verify the expires_in is very long (100 years = ~3.15B seconds)
// We'll check it's greater than 10 years (315M seconds)
expect(tokenResponse.expires_in).toBeGreaterThan(315_000_000);

console.log('✅ Received API key as access token');
console.log(`API key length: ${tokenResponse.access_token.length}`);
console.log(
`Expires in: ${tokenResponse.expires_in} seconds (~${Math.floor(tokenResponse.expires_in / 31536000)} years)`
);

// Verify the API key can be used to authenticate
console.log('Testing API key authentication...');
const balance = await echoControlApi.getBalance(
tokenResponse.access_token
);

expect(balance).toBeDefined();
expect(typeof balance.balance).toBe('number');

console.log('✅ API key successfully used for authentication');
});

test('should return normal JWT when api_key:create scope is NOT requested', async () => {
const codeVerifier = generateCodeVerifier();
const codeChallenge = generateCodeChallenge(codeVerifier);
const state = generateState();

console.log('Getting authorization code WITHOUT api_key:create scope...');

const redirectUrl = await echoControlApi.validateOAuthAuthorizeRequest({
client_id: TEST_CLIENT_IDS.primary,
redirect_uri: 'http://localhost:3000/callback',
state,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
scope: 'llm:invoke offline_access', // No api_key:create
prompt: 'none',
});

const callbackUrl = new URL(redirectUrl);
const authCode = callbackUrl.searchParams.get('code');

expect(authCode).toBeTruthy();

// Exchange authorization code for tokens
const tokenResponse = await echoControlApi.exchangeCodeForToken({
code: authCode!,
client_id: TEST_CLIENT_IDS.primary,
redirect_uri: 'http://localhost:3000/callback',
code_verifier: codeVerifier,
});

// Verify the access token IS a JWT (has 3 parts)
const accessTokenParts = tokenResponse.access_token.split('.');
expect(accessTokenParts.length).toBe(3); // Is a JWT

// JWT expiration should be short (not 100 years)
expect(tokenResponse.expires_in).toBeLessThan(31_536_000); // Less than 1 year

console.log('✅ Received normal JWT token without api_key:create scope');
});

test('should handle mixed scopes correctly (llm:invoke + api_key:create)', async () => {
const codeVerifier = generateCodeVerifier();
const codeChallenge = generateCodeChallenge(codeVerifier);
const state = generateState();

const redirectUrl = await echoControlApi.validateOAuthAuthorizeRequest({
client_id: TEST_CLIENT_IDS.primary,
redirect_uri: 'http://localhost:3000/callback',
state,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
scope: 'llm:invoke api_key:create', // Mixed scopes
prompt: 'none',
});

const callbackUrl = new URL(redirectUrl);
const authCode = callbackUrl.searchParams.get('code');

expect(authCode).toBeTruthy();

const tokenResponse = await echoControlApi.exchangeCodeForToken({
code: authCode!,
client_id: TEST_CLIENT_IDS.primary,
redirect_uri: 'http://localhost:3000/callback',
code_verifier: codeVerifier,
});

// Should return API key because api_key:create is present
expect(tokenResponse.access_token).toMatch(/^echo_/);
expect(tokenResponse.scope).toContain('llm:invoke');
expect(tokenResponse.scope).toContain('api_key:create');

console.log('✅ Mixed scopes handled correctly');
});
});
});
Loading