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,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;
}
}