mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-04-26 08:56:22 +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,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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue