use openid-client lib

This commit is contained in:
Ramnique Singh 2026-01-07 12:29:20 +05:30
parent 642dd7a167
commit 13f2b7c2a5
8 changed files with 414 additions and 476 deletions

View file

@ -1,25 +1,20 @@
import { BrowserWindow } from 'electron';
import { randomBytes } from 'crypto';
import { createAuthServer } from './auth-server.js';
import { generateCodeVerifier, generateCodeChallenge } from '@x/core/dist/auth/pkce.js';
import { OAuthService } from '@x/core/dist/auth/oauth.js';
import * as oauthClient from '@x/core/dist/auth/oauth-client.js';
import type { Configuration } from '@x/core/dist/auth/oauth-client.js';
import { getProviderConfig, getAvailableProviders } from '@x/core/dist/auth/providers.js';
import { discoverAuthorizationServer, createStaticMetadata, AuthorizationServerMetadata } from '@x/core/dist/auth/discovery.js';
import container from '@x/core/dist/di/container.js';
import { IOAuthRepo } from '@x/core/dist/auth/repo.js';
import { IClientRegistrationRepo } from '@x/core/dist/auth/client-repo.js';
const REDIRECT_URI = 'http://localhost:8080/oauth/callback';
// Store active OAuth flows (state -> { codeVerifier, provider })
const activeFlows = new Map<string, { codeVerifier: string; provider: string }>();
/**
* Generate a random state string for CSRF protection
*/
function generateState(): string {
return randomBytes(32).toString('hex');
}
// Store active OAuth flows (state -> { codeVerifier, provider, config })
const activeFlows = new Map<string, {
codeVerifier: string;
provider: string;
config: Configuration;
}>();
/**
* Get OAuth repository from DI container
@ -36,73 +31,63 @@ function getClientRegistrationRepo(): IClientRegistrationRepo {
}
/**
* Discover or get provider metadata
* Get or create OAuth configuration for a provider
*/
async function getProviderMetadata(provider: string): Promise<AuthorizationServerMetadata> {
async function getProviderConfiguration(provider: string): Promise<Configuration> {
const config = getProviderConfig(provider);
if (config.discovery.mode === 'issuer') {
// Discover endpoints from well-known
console.log(`[OAuth] Discovering metadata for ${provider} from issuer: ${config.discovery.issuer}`);
return await discoverAuthorizationServer(config.discovery.issuer);
if (config.client.mode === 'static') {
// Discover endpoints, use static client ID
console.log(`[OAuth] ${provider}: Discovery from issuer with static client ID`);
return await oauthClient.discoverConfiguration(
config.discovery.issuer,
config.client.clientId
);
} else {
// DCR mode - check for existing registration or register new
console.log(`[OAuth] ${provider}: Discovery from issuer with DCR`);
const clientRepo = getClientRegistrationRepo();
const existingRegistration = await clientRepo.getClientRegistration(provider);
if (existingRegistration) {
console.log(`[OAuth] ${provider}: Using existing DCR registration`);
return await oauthClient.discoverConfiguration(
config.discovery.issuer,
existingRegistration.client_id
);
}
// Register new client
const scopes = config.scopes || [];
const { config: oauthConfig, registration } = await oauthClient.registerClient(
config.discovery.issuer,
[REDIRECT_URI],
scopes
);
// Save registration for future use
await clientRepo.saveClientRegistration(provider, registration);
console.log(`[OAuth] ${provider}: DCR registration saved`);
return oauthConfig;
}
} else {
// Use static endpoints
console.log(`[OAuth] Using static metadata for ${provider} (no discovery)`);
return createStaticMetadata(
// Static endpoints mode
if (config.client.mode !== 'static') {
throw new Error('DCR requires discovery mode "issuer", not "static"');
}
console.log(`[OAuth] ${provider}: Using static endpoints (no discovery)`);
return oauthClient.createStaticConfiguration(
config.discovery.authorizationEndpoint,
config.discovery.tokenEndpoint,
config.client.clientId,
config.discovery.revocationEndpoint
);
}
}
/**
* Get or register client ID based on provider configuration
*/
async function getOrRegisterClient(
provider: string,
metadata: AuthorizationServerMetadata,
scopes: string[]
): Promise<string> {
const config = getProviderConfig(provider);
const clientRepo = getClientRegistrationRepo();
if (config.client.mode === 'static') {
// Use static client ID
if (!config.client.clientId) {
throw new Error('Static client mode requires clientId in provider configuration');
}
console.log(`[OAuth] Using static client ID for ${provider}`);
return config.client.clientId;
} else {
// DCR mode - check if registration endpoint exists
const registrationEndpoint = config.client.registrationEndpoint || metadata.registration_endpoint;
if (!registrationEndpoint) {
throw new Error('Provider does not support Dynamic Client Registration (no registration_endpoint found)');
}
// Check for existing registered client
const existingRegistration = await clientRepo.getClientRegistration(provider);
if (existingRegistration) {
console.log(`[OAuth] Using existing DCR client registration for ${provider}`);
return existingRegistration.client_id;
}
// Register new client - create temporary service just for registration
// We need to pass a dummy clientId, but it won't be used for registration
console.log(`[OAuth] Registering new client via DCR for ${provider}...`);
const tempService = new OAuthService(metadata, 'temp', scopes);
const registration = await tempService.registerClient([REDIRECT_URI], scopes);
// Save registration
await clientRepo.saveClientRegistration(provider, registration);
console.log(`[OAuth] DCR registration successful for ${provider}, client_id: ${registration.client_id}`);
return registration.client_id;
}
}
/**
* Initiate OAuth flow for a provider
*/
@ -110,32 +95,20 @@ export async function connectProvider(provider: string): Promise<{ success: bool
try {
console.log(`[OAuth] Starting connection flow for ${provider}...`);
const oauthRepo = getOAuthRepo();
const config = getProviderConfig(provider);
const providerConfig = getProviderConfig(provider);
// Validate configuration combinations
if (config.discovery.mode === 'static' && config.client.mode === 'dcr') {
throw new Error('DCR requires discovery mode "issuer", not "static"');
}
// Get provider metadata (discover or use static)
const metadata = await getProviderMetadata(provider);
// Get scopes from config or use empty array
const scopes = config.scopes || [];
// Get or register client ID
const clientId = await getOrRegisterClient(provider, metadata, scopes);
// Create OAuth service with metadata and client ID
const oauthService = new OAuthService(metadata, clientId, scopes);
// Get or create OAuth configuration
const config = await getProviderConfiguration(provider);
// Generate PKCE codes
const codeVerifier = generateCodeVerifier();
const codeChallenge = generateCodeChallenge(codeVerifier);
const state = generateState();
const { verifier: codeVerifier, challenge: codeChallenge } = await oauthClient.generatePKCE();
const state = oauthClient.generateState();
// Get scopes from config
const scopes = providerConfig.scopes || [];
// Store flow state
activeFlows.set(state, { codeVerifier, provider });
activeFlows.set(state, { codeVerifier, provider, config });
// Create callback server
const { server } = await createAuthServer(8080, async (code, receivedState) => {
@ -150,12 +123,16 @@ export async function connectProvider(provider: string): Promise<{ success: bool
}
try {
// Build callback URL for token exchange
const callbackUrl = new URL(`${REDIRECT_URI}?code=${code}&state=${receivedState}`);
// Exchange code for tokens
console.log(`[OAuth] Exchanging authorization code for tokens (${provider})...`);
const tokens = await oauthService.exchangeCodeForTokens(
code,
const tokens = await oauthClient.exchangeCodeForTokens(
flow.config,
callbackUrl,
flow.codeVerifier,
REDIRECT_URI
state
);
// Save tokens
@ -172,7 +149,12 @@ export async function connectProvider(provider: string): Promise<{ success: bool
});
// Build authorization URL
const authUrl = oauthService.buildAuthorizationUrl(codeChallenge, state, REDIRECT_URI);
const authUrl = oauthClient.buildAuthorizationUrl(config, {
redirectUri: REDIRECT_URI,
scope: scopes.join(' '),
codeChallenge,
state,
});
// Open browser window
const authWindow = new BrowserWindow({
@ -185,7 +167,7 @@ export async function connectProvider(provider: string): Promise<{ success: bool
},
});
authWindow.loadURL(authUrl);
authWindow.loadURL(authUrl.toString());
// Clean up on window close
authWindow.on('closed', () => {
@ -239,34 +221,26 @@ export async function isConnected(provider: string): Promise<{ isConnected: bool
export async function getAccessToken(provider: string): Promise<string | null> {
try {
const oauthRepo = getOAuthRepo();
const config = getProviderConfig(provider);
let tokens = await oauthRepo.getTokens(provider);
if (!tokens) {
return null;
}
// Get provider metadata
const metadata = await getProviderMetadata(provider);
// Get client ID (static or registered)
const clientId = await getOrRegisterClient(provider, metadata, config.scopes || []);
// Create OAuth service
const scopes = config.scopes || [];
const oauthService = new OAuthService(metadata, clientId, scopes);
// Check if token needs refresh
if (oauthService.isTokenExpired(tokens)) {
if (oauthClient.isTokenExpired(tokens)) {
if (!tokens.refresh_token) {
// No refresh token, need to reconnect
return null;
}
try {
// Get configuration for refresh
const config = await getProviderConfiguration(provider);
// Refresh token, preserving existing scopes
const existingScopes = tokens.scopes;
tokens = await oauthService.refreshAccessToken(tokens.refresh_token, existingScopes);
tokens = await oauthClient.refreshTokens(config, tokens.refresh_token, existingScopes);
await oauthRepo.saveTokens(provider, tokens);
} catch (error) {
console.error('Token refresh failed:', error);
@ -301,4 +275,3 @@ export async function getConnectedProviders(): Promise<{ providers: string[] }>
export function listProviders(): { providers: string[] } {
return { providers: getAvailableProviders() };
}

View file

@ -25,6 +25,7 @@
"googleapis": "^169.0.0",
"node-html-markdown": "^2.0.0",
"ollama-ai-provider-v2": "^1.5.4",
"openid-client": "^6.8.1",
"yaml": "^2.8.2",
"zod": "^4.2.1"
},

View file

@ -1,86 +0,0 @@
import { z } from 'zod';
/**
* OAuth 2.0 Authorization Server Metadata (RFC 8414)
* Also compatible with OpenID Connect Discovery
*/
export const AuthorizationServerMetadataSchema = z.object({
issuer: z.url(),
authorization_endpoint: z.url(),
token_endpoint: z.url(),
registration_endpoint: z.url().optional(), // Indicates DCR support
revocation_endpoint: z.url().optional(),
jwks_uri: z.url().optional(),
scopes_supported: z.array(z.string()).optional(),
response_types_supported: z.array(z.string()).optional(),
grant_types_supported: z.array(z.string()).optional(),
token_endpoint_auth_methods_supported: z.array(z.string()).optional(),
code_challenge_methods_supported: z.array(z.string()).optional(), // For PKCE
});
export type AuthorizationServerMetadata = z.infer<typeof AuthorizationServerMetadataSchema>;
/**
* Discover OAuth 2.0 authorization server metadata
* Tries RFC 8414 endpoint first, falls back to OIDC Discovery
*/
export async function discoverAuthorizationServer(issuer: string): Promise<AuthorizationServerMetadata> {
// Remove trailing slash from issuer
const baseUrl = issuer.replace(/\/$/, '');
// Try RFC 8414 endpoint first
const rfc8414Url = `${baseUrl}/.well-known/oauth-authorization-server`;
try {
const response = await fetch(rfc8414Url);
if (response.ok) {
console.log(`[OAuth Discovery] Using RFC 8414 endpoint for ${issuer}`);
const metadata = await response.json();
return AuthorizationServerMetadataSchema.parse(metadata);
}
} catch {
// Fall through to OIDC Discovery
}
// Fallback to OpenID Connect Discovery
const oidcUrl = `${baseUrl}/.well-known/openid-configuration`;
try {
console.log(`[OAuth Discovery] Falling back to OIDC discovery for ${issuer}`);
const response = await fetch(oidcUrl);
if (!response.ok) {
throw new Error(`Failed to fetch discovery document: ${response.status} ${response.statusText}`);
}
const metadata = await response.json();
return AuthorizationServerMetadataSchema.parse(metadata);
} catch (error) {
if (error instanceof z.ZodError) {
const errorMessages = error.issues.map(issue => {
const path = issue.path.length > 0 ? issue.path.join('.') : 'root';
return `${path}: ${issue.message}`;
}).join(', ');
throw new Error(
`Invalid authorization server metadata from ${issuer}: ${errorMessages}`
);
}
throw new Error(
`Failed to discover authorization server metadata from ${issuer}: ${error instanceof Error ? error.message : String(error)}`
);
}
}
/**
* Construct metadata from static endpoints
*/
export function createStaticMetadata(
authorizationEndpoint: string,
tokenEndpoint: string,
revocationEndpoint?: string
): AuthorizationServerMetadata {
console.log(`[OAuth Discovery] Using static endpoints (no discovery)`);
return AuthorizationServerMetadataSchema.parse({
issuer: new URL(authorizationEndpoint).origin,
authorization_endpoint: authorizationEndpoint,
token_endpoint: tokenEndpoint,
revocation_endpoint: revocationEndpoint,
});
}

View file

@ -0,0 +1,246 @@
import * as client from 'openid-client';
import { OAuthTokens, ClientRegistrationResponse } from './types.js';
/**
* Cached configurations per provider (issuer:clientId -> Configuration)
*/
const configCache = new Map<string, client.Configuration>();
/**
* Helper to convert openid-client token response to our OAuthTokens type
*/
function toOAuthTokens(response: client.TokenEndpointResponse): OAuthTokens {
const accessToken = response.access_token;
const refreshToken = response.refresh_token ?? null;
// Calculate expires_at from expires_in
const expiresIn = response.expires_in ?? 3600;
const expiresAt = Math.floor(Date.now() / 1000) + expiresIn;
// Parse scopes from space-separated string
let scopes: string[] | undefined;
if (response.scope) {
scopes = response.scope.split(' ').filter(s => s.length > 0);
}
return OAuthTokens.parse({
access_token: accessToken,
refresh_token: refreshToken,
expires_at: expiresAt,
token_type: 'Bearer',
scopes,
});
}
/**
* Discover authorization server metadata and create configuration
*/
export async function discoverConfiguration(
issuerUrl: string,
clientId: string
): Promise<client.Configuration> {
const cacheKey = `${issuerUrl}:${clientId}`;
const cached = configCache.get(cacheKey);
if (cached) {
console.log(`[OAuth] Using cached configuration for ${issuerUrl}`);
return cached;
}
console.log(`[OAuth] Discovering authorization server metadata for ${issuerUrl}...`);
const config = await client.discovery(
new URL(issuerUrl),
clientId,
undefined, // no client_secret (PKCE flow)
client.None() // PKCE doesn't require client authentication
);
configCache.set(cacheKey, config);
console.log(`[OAuth] Discovery complete for ${issuerUrl}`);
return config;
}
/**
* Create configuration from static endpoints (no discovery)
*/
export function createStaticConfiguration(
authorizationEndpoint: string,
tokenEndpoint: string,
clientId: string,
revocationEndpoint?: string
): client.Configuration {
console.log(`[OAuth] Creating static configuration (no discovery)`);
const issuer = new URL(authorizationEndpoint).origin;
// Create Configuration with static metadata
const serverMetadata: client.ServerMetadata = {
issuer,
authorization_endpoint: authorizationEndpoint,
token_endpoint: tokenEndpoint,
revocation_endpoint: revocationEndpoint,
};
return new client.Configuration(
serverMetadata,
clientId,
undefined, // no client_secret
client.None() // PKCE auth
);
}
/**
* Register client via Dynamic Client Registration (RFC 7591)
* Returns both the Configuration and the registration response (for persistence)
*/
export async function registerClient(
issuerUrl: string,
redirectUris: string[],
scopes: string[],
clientName: string = 'RowboatX Desktop App'
): Promise<{ config: client.Configuration; registration: ClientRegistrationResponse }> {
console.log(`[OAuth] Registering client via DCR at ${issuerUrl}...`);
const config = await client.dynamicClientRegistration(
new URL(issuerUrl),
{
redirect_uris: redirectUris,
token_endpoint_auth_method: 'none', // PKCE flow
grant_types: ['authorization_code', 'refresh_token'],
response_types: ['code'],
client_name: clientName,
scope: scopes.join(' '),
},
client.None()
);
const metadata = config.clientMetadata();
console.log(`[OAuth] DCR complete, client_id: ${metadata.client_id}`);
// Extract registration response for persistence
const registration = ClientRegistrationResponse.parse({
client_id: metadata.client_id,
client_secret: metadata.client_secret,
client_id_issued_at: metadata.client_id_issued_at,
client_secret_expires_at: metadata.client_secret_expires_at,
});
// Cache the configuration
const cacheKey = `${issuerUrl}:${metadata.client_id}`;
configCache.set(cacheKey, config);
return { config, registration };
}
/**
* Generate PKCE code verifier and challenge
*/
export async function generatePKCE(): Promise<{ verifier: string; challenge: string }> {
const verifier = client.randomPKCECodeVerifier();
const challenge = await client.calculatePKCECodeChallenge(verifier);
return { verifier, challenge };
}
/**
* Generate random state for CSRF protection
*/
export function generateState(): string {
return client.randomState();
}
/**
* Build authorization URL with PKCE
*/
export function buildAuthorizationUrl(
config: client.Configuration,
params: {
redirectUri: string;
scope: string;
codeChallenge: string;
state: string;
}
): URL {
return client.buildAuthorizationUrl(config, {
redirect_uri: params.redirectUri,
scope: params.scope,
code_challenge: params.codeChallenge,
code_challenge_method: 'S256',
state: params.state,
});
}
/**
* Exchange authorization code for tokens
*/
export async function exchangeCodeForTokens(
config: client.Configuration,
callbackUrl: URL,
codeVerifier: string,
expectedState: string
): Promise<OAuthTokens> {
console.log(`[OAuth] Exchanging authorization code for tokens...`);
const response = await client.authorizationCodeGrant(config, callbackUrl, {
pkceCodeVerifier: codeVerifier,
expectedState,
});
console.log(`[OAuth] Token exchange successful`);
return toOAuthTokens(response);
}
/**
* Refresh access token using refresh token
* Preserves existing scopes if not returned by server
*/
export async function refreshTokens(
config: client.Configuration,
refreshToken: string,
existingScopes?: string[]
): Promise<OAuthTokens> {
console.log(`[OAuth] Refreshing access token...`);
const response = await client.refreshTokenGrant(config, refreshToken);
const tokens = toOAuthTokens(response);
// Preserve existing scopes if server didn't return them
if (!tokens.scopes && existingScopes) {
tokens.scopes = existingScopes;
}
console.log(`[OAuth] Token refresh successful`);
return tokens;
}
/**
* Check if tokens are expired
*/
export function isTokenExpired(tokens: OAuthTokens): boolean {
const now = Math.floor(Date.now() / 1000);
return tokens.expires_at <= now;
}
/**
* Clear configuration cache for a specific provider or all providers
*/
export function clearConfigCache(issuerUrl?: string, clientId?: string): void {
if (issuerUrl && clientId) {
configCache.delete(`${issuerUrl}:${clientId}`);
console.log(`[OAuth] Cleared configuration cache for ${issuerUrl}`);
} else {
configCache.clear();
console.log(`[OAuth] Cleared all configuration cache`);
}
}
/**
* Get cached configuration if available
*/
export function getCachedConfiguration(issuerUrl: string, clientId: string): client.Configuration | undefined {
return configCache.get(`${issuerUrl}:${clientId}`);
}
// Re-export Configuration type for external use
export type { Configuration } from 'openid-client';

View file

@ -1,184 +0,0 @@
import { AuthorizationServerMetadata } from './discovery.js';
import { OAuthTokens, ClientRegistrationRequest, ClientRegistrationResponse } from './types.js';
/**
* Generic OAuth 2.0 service with PKCE support
*/
export class OAuthService {
constructor(
private metadata: AuthorizationServerMetadata,
private clientId: string,
private scopes: string[]
) {}
/**
* Build authorization URL with PKCE parameters
*/
buildAuthorizationUrl(
codeChallenge: string,
state: string,
redirectUri: string
): string {
const params = new URLSearchParams({
client_id: this.clientId,
redirect_uri: redirectUri,
response_type: 'code',
scope: this.scopes.join(' '),
state,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
});
return `${this.metadata.authorization_endpoint}?${params.toString()}`;
}
/**
* Exchange authorization code for tokens using PKCE
*/
async exchangeCodeForTokens(
code: string,
codeVerifier: string,
redirectUri: string
): Promise<OAuthTokens> {
const params = new URLSearchParams({
client_id: this.clientId,
code,
redirect_uri: redirectUri,
grant_type: 'authorization_code',
code_verifier: codeVerifier,
});
const response = await fetch(this.metadata.token_endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: params.toString(),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Token exchange failed: ${response.status} ${errorText}`);
}
const data = await response.json();
// Calculate expires_at from expires_in if provided
const expiresIn = data.expires_in || 3600; // Default to 1 hour
const expiresAt = Math.floor(Date.now() / 1000) + expiresIn;
// Extract granted scopes from response (may be space-separated string or array)
let scopes: string[] | undefined;
if (data.scope) {
if (typeof data.scope === 'string') {
scopes = data.scope.split(' ').filter((s: string) => s.length > 0);
} else if (Array.isArray(data.scope)) {
scopes = data.scope;
}
}
return OAuthTokens.parse({
access_token: data.access_token,
refresh_token: data.refresh_token || null,
expires_at: expiresAt,
token_type: data.token_type || 'Bearer',
scopes,
});
}
/**
* Refresh access token using refresh token
* Preserves existing scopes since refresh responses typically don't include them
*/
async refreshAccessToken(refreshToken: string, existingScopes?: string[]): Promise<OAuthTokens> {
const params = new URLSearchParams({
client_id: this.clientId,
refresh_token: refreshToken,
grant_type: 'refresh_token',
});
const response = await fetch(this.metadata.token_endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: params.toString(),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Token refresh failed: ${response.status} ${errorText}`);
}
const data = await response.json();
// Calculate expires_at from expires_in if provided
const expiresIn = data.expires_in || 3600;
const expiresAt = Math.floor(Date.now() / 1000) + expiresIn;
// Extract scopes from refresh response if provided, otherwise preserve existing scopes
let scopes: string[] | undefined = existingScopes;
if (data.scope) {
if (typeof data.scope === 'string') {
scopes = data.scope.split(' ').filter((s: string) => s.length > 0);
} else if (Array.isArray(data.scope)) {
scopes = data.scope;
}
}
return OAuthTokens.parse({
access_token: data.access_token,
refresh_token: data.refresh_token || refreshToken, // Some providers don't return new refresh token
expires_at: expiresAt,
token_type: data.token_type || 'Bearer',
scopes,
});
}
/**
* Check if tokens are expired
*/
isTokenExpired(tokens: OAuthTokens): boolean {
const now = Math.floor(Date.now() / 1000);
return tokens.expires_at <= now;
}
/**
* Register client using Dynamic Client Registration (RFC 7591)
*/
async registerClient(
redirectUris: string[],
scopes: string[]
): Promise<ClientRegistrationResponse> {
if (!this.metadata.registration_endpoint) {
throw new Error('Provider does not support Dynamic Client Registration');
}
const registrationRequest: ClientRegistrationRequest = {
redirect_uris: redirectUris,
token_endpoint_auth_method: 'none', // PKCE doesn't need client secret
grant_types: ['authorization_code', 'refresh_token'],
response_types: ['code'],
client_name: 'RowboatX Desktop App',
scope: scopes.join(' '),
};
const response = await fetch(this.metadata.registration_endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify(registrationRequest),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Client registration failed: ${response.status} ${errorText}`);
}
const data = await response.json();
return ClientRegistrationResponse.parse(data);
}
}

View file

@ -1,34 +0,0 @@
import { randomBytes, createHash } from 'crypto';
/**
* Generate a random code verifier for PKCE
* Returns a base64url-encoded string of 128 characters
*/
export function generateCodeVerifier(): string {
// Generate 96 random bytes (768 bits) to ensure we have enough entropy
// After base64url encoding, this will be 128 characters
const bytes = randomBytes(96);
return base64UrlEncode(bytes);
}
/**
* Generate a code challenge from a code verifier
* Uses SHA256 hash and base64url encoding
*/
export function generateCodeChallenge(verifier: string): string {
const hash = createHash('sha256').update(verifier).digest();
return base64UrlEncode(hash);
}
/**
* Base64url encode (RFC 4648)
* Replaces + with -, / with _, and removes padding
*/
function base64UrlEncode(buffer: Buffer): string {
return buffer
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
}

View file

@ -3,8 +3,8 @@ import container from '../di/container.js';
import { IOAuthRepo } from '../auth/repo.js';
import { IClientRegistrationRepo } from '../auth/client-repo.js';
import { getProviderConfig } from '../auth/providers.js';
import { OAuthService } from '../auth/oauth.js';
import { discoverAuthorizationServer, createStaticMetadata, AuthorizationServerMetadata } from '../auth/discovery.js';
import * as oauthClient from '../auth/oauth-client.js';
import type { Configuration } from '../auth/oauth-client.js';
import { OAuthTokens } from '../auth/types.js';
/**
@ -14,13 +14,11 @@ import { OAuthTokens } from '../auth/types.js';
export class GoogleClientFactory {
private static readonly PROVIDER_NAME = 'google';
private static cache: {
metadata: AuthorizationServerMetadata | null;
clientId: string | null;
config: Configuration | null;
client: OAuth2Client | null;
tokens: OAuthTokens | null;
} = {
metadata: null,
clientId: null,
config: null,
client: null,
tokens: null,
};
@ -37,35 +35,34 @@ export class GoogleClientFactory {
return null;
}
// Initialize auth cache if needed
await this.initializeAuthCache();
if (!this.cache.metadata || !this.cache.clientId) {
// Initialize config cache if needed
await this.initializeConfigCache();
if (!this.cache.config) {
return null;
}
// Check if token is expired
const now = Math.floor(Date.now() / 1000);
if (tokens.expires_at <= now) {
if (oauthClient.isTokenExpired(tokens)) {
// Token expired, try to refresh
if (!tokens.refresh_token) {
console.log("Token expired and no refresh token available for Google.");
console.log("[OAuth] Token expired and no refresh token available for Google.");
this.clearCache();
return null;
}
try {
console.log(`[OAuth] Token expired, refreshing access token...`);
const config = getProviderConfig(this.PROVIDER_NAME);
const scopes = config.scopes || [];
const oauthService = new OAuthService(this.cache.metadata, this.cache.clientId, scopes);
const existingScopes = tokens.scopes;
const refreshedTokens = await oauthService.refreshAccessToken(tokens.refresh_token, existingScopes);
const refreshedTokens = await oauthClient.refreshTokens(
this.cache.config,
tokens.refresh_token,
existingScopes
);
await oauthRepo.saveTokens(this.PROVIDER_NAME, refreshedTokens);
// Update cached tokens and recreate client
this.cache.tokens = refreshedTokens;
this.cache.client = this.createClientFromTokens(refreshedTokens, this.cache.clientId);
this.cache.client = this.createClientFromTokens(refreshedTokens);
console.log(`[OAuth] Token refreshed successfully`);
return this.cache.client;
} catch (error) {
@ -83,7 +80,7 @@ export class GoogleClientFactory {
// Create new client with current tokens
console.log(`[OAuth] Creating new OAuth2Client instance`);
this.cache.tokens = tokens;
this.cache.client = this.createClientFromTokens(tokens, this.cache.clientId);
this.cache.client = this.createClientFromTokens(tokens);
return this.cache.client;
}
@ -116,69 +113,79 @@ export class GoogleClientFactory {
*/
static clearCache(): void {
console.log(`[OAuth] Clearing Google auth cache`);
this.cache.config = null;
this.cache.client = null;
this.cache.tokens = null;
}
/**
* Initialize cached metadata and client ID (called once)
* Initialize cached configuration (called once)
*/
private static async initializeAuthCache(): Promise<void> {
if (this.cache.metadata && this.cache.clientId) {
private static async initializeConfigCache(): Promise<void> {
if (this.cache.config) {
return; // Already initialized
}
console.log(`[OAuth] Initializing Google auth cache...`);
const config = getProviderConfig(this.PROVIDER_NAME);
console.log(`[OAuth] Initializing Google OAuth configuration...`);
const providerConfig = getProviderConfig(this.PROVIDER_NAME);
// Get metadata
let metadata: AuthorizationServerMetadata;
if (config.discovery.mode === 'issuer') {
console.log(`[OAuth] Discovery mode: issuer (${config.discovery.issuer})`);
metadata = await discoverAuthorizationServer(config.discovery.issuer);
if (providerConfig.discovery.mode === 'issuer') {
if (providerConfig.client.mode === 'static') {
// Discover endpoints, use static client ID
console.log(`[OAuth] Discovery mode: issuer with static client ID`);
this.cache.config = await oauthClient.discoverConfiguration(
providerConfig.discovery.issuer,
providerConfig.client.clientId
);
} else {
// DCR mode - need existing registration
console.log(`[OAuth] Discovery mode: issuer with DCR`);
const clientRepo = container.resolve<IClientRegistrationRepo>('clientRegistrationRepo');
const existingRegistration = await clientRepo.getClientRegistration(this.PROVIDER_NAME);
if (!existingRegistration) {
throw new Error('Google client not registered. Please connect account first.');
}
this.cache.config = await oauthClient.discoverConfiguration(
providerConfig.discovery.issuer,
existingRegistration.client_id
);
}
} else {
console.log(`[OAuth] Discovery mode: static endpoints`);
metadata = createStaticMetadata(
config.discovery.authorizationEndpoint,
config.discovery.tokenEndpoint,
config.discovery.revocationEndpoint
// Static endpoints
if (providerConfig.client.mode !== 'static') {
throw new Error('DCR requires discovery mode "issuer", not "static"');
}
console.log(`[OAuth] Using static endpoints (no discovery)`);
this.cache.config = oauthClient.createStaticConfiguration(
providerConfig.discovery.authorizationEndpoint,
providerConfig.discovery.tokenEndpoint,
providerConfig.client.clientId,
providerConfig.discovery.revocationEndpoint
);
}
// Get client ID
let clientId: string;
if (config.client.mode === 'static') {
if (!config.client.clientId) {
throw new Error('Static client mode requires clientId in provider configuration for Google');
}
console.log(`[OAuth] Client mode: static (using configured clientId)`);
clientId = config.client.clientId;
} else {
console.log(`[OAuth] Client mode: DCR (Dynamic Client Registration)`);
const clientRepo = container.resolve<IClientRegistrationRepo>('clientRegistrationRepo');
const registrationEndpoint = config.client.registrationEndpoint || metadata.registration_endpoint;
if (!registrationEndpoint) {
throw new Error('Google provider does not support Dynamic Client Registration');
}
const existingRegistration = await clientRepo.getClientRegistration(this.PROVIDER_NAME);
if (!existingRegistration) {
throw new Error('Google client not registered. Please connect account first.');
}
console.log(`[OAuth] Using existing DCR client registration`);
clientId = existingRegistration.client_id;
}
// Store in cache
this.cache.metadata = metadata;
this.cache.clientId = clientId;
console.log(`[OAuth] Google auth cache initialized`);
console.log(`[OAuth] Google OAuth configuration initialized`);
}
/**
* Create OAuth2Client from OAuthTokens
*/
private static createClientFromTokens(tokens: OAuthTokens, clientId: string): OAuth2Client {
private static createClientFromTokens(tokens: OAuthTokens): OAuth2Client {
const providerConfig = getProviderConfig(this.PROVIDER_NAME);
// Get client ID from config
let clientId: string;
if (providerConfig.client.mode === 'static') {
clientId = providerConfig.client.clientId;
} else {
// For DCR, we'd need to look up the registered client ID
// This is a fallback - normally initializeConfigCache handles this
throw new Error('Cannot create client without static client ID');
}
// Create OAuth2Client directly (PKCE flow doesn't use client secret)
const client = new OAuth2Client(
clientId,
@ -197,4 +204,3 @@ export class GoogleClientFactory {
return client;
}
}

16
apps/x/pnpm-lock.yaml generated
View file

@ -260,6 +260,9 @@ importers:
ollama-ai-provider-v2:
specifier: ^1.5.4
version: 1.5.5(zod@4.2.1)
openid-client:
specifier: ^6.8.1
version: 6.8.1
yaml:
specifier: ^2.8.2
version: 2.8.2
@ -3493,6 +3496,9 @@ packages:
nth-check@2.1.1:
resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
oauth4webapi@3.8.3:
resolution: {integrity: sha512-pQ5BsX3QRTgnt5HxgHwgunIRaDXBdkT23tf8dfzmtTIL2LTpdmxgbpbBm0VgFWAIDlezQvQCTgnVIUmHupXHxw==}
object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
@ -3528,6 +3534,9 @@ packages:
resolution: {integrity: sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==}
engines: {node: '>=8'}
openid-client@6.8.1:
resolution: {integrity: sha512-VoYT6enBo6Vj2j3Q5Ec0AezS+9YGzQo1f5Xc42lreMGlfP4ljiXPKVDvCADh+XHCV/bqPu/wWSiCVXbJKvrODw==}
optionator@0.9.4:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'}
@ -7848,6 +7857,8 @@ snapshots:
dependencies:
boolbase: 1.0.0
oauth4webapi@3.8.3: {}
object-assign@4.1.1: {}
object-inspect@1.13.4: {}
@ -7882,6 +7893,11 @@ snapshots:
is-docker: 2.2.1
is-wsl: 2.2.0
openid-client@6.8.1:
dependencies:
jose: 6.1.3
oauth4webapi: 3.8.3
optionator@0.9.4:
dependencies:
deep-is: 0.1.4