mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-04-30 10:56:29 +02:00
use openid-client lib
This commit is contained in:
parent
642dd7a167
commit
13f2b7c2a5
8 changed files with 414 additions and 476 deletions
|
|
@ -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() };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
246
apps/x/packages/core/src/auth/oauth-client.ts
Normal file
246
apps/x/packages/core/src/auth/oauth-client.ts
Normal 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';
|
||||
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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, '');
|
||||
}
|
||||
|
||||
|
|
@ -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
16
apps/x/pnpm-lock.yaml
generated
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue