fix oauth package name

This commit is contained in:
2025-06-19 11:56:58 +09:00
parent b17ac3d91a
commit 30bdd7b633
105 changed files with 1116 additions and 8739 deletions

206
oauth/src/utils/avatar.js Normal file
View File

@@ -0,0 +1,206 @@
import React from 'react'
import { atproto } from '../api/atproto.js'
import { getPdsFromHandle, getApiConfig } from './pds.js'
import { dataCache } from './cache.js'
import { logError } from './errorHandler.js'
// Cache duration for avatar URLs (30 minutes)
const AVATAR_CACHE_DURATION = 30 * 60 * 1000
/**
* Avatar fetching utility with fallback mechanism
*
* Strategy:
* 1. First check if avatar exists in the record
* 2. If avatar is missing/broken, fetch fresh data from ATProto
* 3. Cache results to avoid excessive API calls
*/
/**
* Extract avatar URL from record if available
* @param {Object} record - The record object
* @returns {string|null} Avatar URL or null
*/
function getAvatarFromRecord(record) {
const avatar = record?.value?.author?.avatar
if (avatar && typeof avatar === 'string' && avatar.startsWith('http')) {
return avatar
}
return null
}
/**
* Fetch fresh avatar data from ATProto
* @param {string} handle - User handle
* @param {string} did - User DID (optional, will fetch if not provided)
* @returns {Promise<string|null>} Avatar URL or null
*/
async function fetchFreshAvatar(handle, did = null) {
try {
// Step 1: Get PDS from handle
const pds = await getPdsFromHandle(handle)
const apiConfig = getApiConfig(pds)
// Step 2: Get DID if not provided
if (!did) {
const pdsHost = pds.replace(/^https?:\/\//, '')
const repoData = await atproto.getDid(pdsHost, handle)
did = repoData
}
// Step 3: Get profile from bsky API
const profile = await atproto.getProfile(apiConfig.bsky, did)
// Return avatar URL
return profile?.avatar || null
} catch (error) {
logError(error, 'Avatar Fetch')
return null
}
}
/**
* Get avatar with intelligent fallback
* @param {Object} options - Options object
* @param {Object} options.record - Record object (optional)
* @param {string} options.handle - User handle (required if no record)
* @param {string} options.did - User DID (optional)
* @param {boolean} options.forceFresh - Force fresh fetch even if cached
* @returns {Promise<string|null>} Avatar URL or null
*/
export async function getAvatar({ record, handle, did, forceFresh = false }) {
// Extract handle and DID from record if available
if (record && !handle) {
handle = record.value?.author?.handle
did = record.value?.author?.did
}
if (!handle) {
throw new Error('Handle is required to fetch avatar')
}
// Generate cache key
const cacheKey = `avatar:${handle}`
// Check cache first (unless forceFresh)
if (!forceFresh) {
const cached = dataCache.get(cacheKey)
if (cached) {
return cached
}
}
// Try to get avatar from record first
if (record) {
const recordAvatar = getAvatarFromRecord(record)
if (recordAvatar) {
// Validate that the avatar URL is still accessible
try {
const response = await fetch(recordAvatar, { method: 'HEAD' })
if (response.ok) {
dataCache.set(cacheKey, recordAvatar, AVATAR_CACHE_DURATION)
return recordAvatar
}
} catch {
// Avatar URL is broken, proceed to fetch fresh
}
}
}
// Fetch fresh avatar data
const freshAvatar = await fetchFreshAvatar(handle, did)
if (freshAvatar) {
dataCache.set(cacheKey, freshAvatar, AVATAR_CACHE_DURATION)
}
return freshAvatar
}
/**
* Batch fetch avatars for multiple users
* @param {Array<Object>} users - Array of user objects with handle/did
* @returns {Promise<Map>} Map of handle -> avatar URL
*/
export async function batchFetchAvatars(users) {
const avatarMap = new Map()
// Process in parallel with concurrency limit
const BATCH_SIZE = 5
for (let i = 0; i < users.length; i += BATCH_SIZE) {
const batch = users.slice(i, i + BATCH_SIZE)
const promises = batch.map(async (user) => {
const avatar = await getAvatar({
handle: user.handle,
did: user.did
})
return { handle: user.handle, avatar }
})
const results = await Promise.all(promises)
results.forEach(({ handle, avatar }) => {
avatarMap.set(handle, avatar)
})
}
return avatarMap
}
/**
* Prefetch and cache avatar for a handle
* @param {string} handle - User handle
* @returns {Promise<void>}
*/
export async function prefetchAvatar(handle) {
await getAvatar({ handle })
}
/**
* Clear avatar cache for a specific handle
* @param {string} handle - User handle
*/
export function clearAvatarCache(handle) {
if (handle) {
dataCache.delete(`avatar:${handle}`)
}
}
/**
* Clear all avatar caches
*/
export function clearAllAvatarCaches() {
dataCache.invalidatePattern('avatar:')
}
/**
* React hook for avatar management
* @param {Object} options - Options for avatar fetching
* @returns {Object} { avatar, loading, error, refetch }
*/
export function useAvatar({ record, handle, did }) {
const [state, setState] = React.useState({
avatar: null,
loading: true,
error: null
})
const fetchAvatar = React.useCallback(async (forceFresh = false) => {
setState(prev => ({ ...prev, loading: true, error: null }))
try {
const avatarUrl = await getAvatar({ record, handle, did, forceFresh })
setState({ avatar: avatarUrl, loading: false, error: null })
} catch (error) {
setState({ avatar: null, loading: false, error: error.message })
}
}, [record, handle, did])
React.useEffect(() => {
fetchAvatar()
}, [fetchAvatar])
return {
...state,
refetch: () => fetchAvatar(true)
}
}

View File

@@ -0,0 +1,262 @@
import { dataCache } from './cache.js'
/**
* Avatar-specific cache utilities
* Extends the base cache system with avatar-specific functionality
*/
// Cache keys
const CACHE_PREFIX = 'avatar:'
const METADATA_KEY = 'avatar:metadata'
/**
* Get cache metadata for avatars
* @returns {Object} Metadata about avatar cache
*/
export function getAvatarCacheMetadata() {
return dataCache.get(METADATA_KEY) || {
totalCount: 0,
lastCleanup: Date.now(),
cacheHits: 0,
cacheMisses: 0
}
}
/**
* Update cache metadata
* @param {Object} updates - Updates to apply to metadata
*/
function updateMetadata(updates) {
const current = getAvatarCacheMetadata()
const updated = { ...current, ...updates }
dataCache.set(METADATA_KEY, updated)
}
/**
* Track cache hit
*/
export function trackCacheHit() {
const metadata = getAvatarCacheMetadata()
updateMetadata({ cacheHits: metadata.cacheHits + 1 })
}
/**
* Track cache miss
*/
export function trackCacheMiss() {
const metadata = getAvatarCacheMetadata()
updateMetadata({ cacheMisses: metadata.cacheMisses + 1 })
}
/**
* Get all cached avatar handles
* @returns {Array<string>} List of cached handles
*/
export function getCachedAvatarHandles() {
// This would require enumerating cache keys
// For now, we'll track this in metadata
const metadata = getAvatarCacheMetadata()
return metadata.handles || []
}
/**
* Add handle to cached list
* @param {string} handle - Handle to add
*/
export function addCachedHandle(handle) {
const metadata = getAvatarCacheMetadata()
const handles = metadata.handles || []
if (!handles.includes(handle)) {
handles.push(handle)
updateMetadata({
handles,
totalCount: handles.length
})
}
}
/**
* Remove handle from cached list
* @param {string} handle - Handle to remove
*/
export function removeCachedHandle(handle) {
const metadata = getAvatarCacheMetadata()
const handles = (metadata.handles || []).filter(h => h !== handle)
updateMetadata({
handles,
totalCount: handles.length
})
}
/**
* Clean up expired avatar cache entries
* @param {number} maxAge - Maximum age in milliseconds (default: 30 minutes)
* @returns {number} Number of entries cleaned
*/
export function cleanupExpiredAvatars(maxAge = 30 * 60 * 1000) {
const now = Date.now()
const metadata = getAvatarCacheMetadata()
const handles = metadata.handles || []
let cleanedCount = 0
handles.forEach(handle => {
const cacheKey = `${CACHE_PREFIX}${handle}`
const entry = dataCache.get(cacheKey, true) // Get with metadata
if (entry && entry.timestamp && (now - entry.timestamp) > maxAge) {
dataCache.delete(cacheKey)
cleanedCount++
}
})
// Update metadata
if (cleanedCount > 0) {
const remainingHandles = handles.filter(handle => {
const cacheKey = `${CACHE_PREFIX}${handle}`
return dataCache.get(cacheKey) !== null
})
updateMetadata({
handles: remainingHandles,
totalCount: remainingHandles.length,
lastCleanup: now
})
}
return cleanedCount
}
/**
* Get cache statistics
* @returns {Object} Cache statistics
*/
export function getAvatarCacheStats() {
const metadata = getAvatarCacheMetadata()
const totalRequests = metadata.cacheHits + metadata.cacheMisses
const hitRate = totalRequests > 0 ? (metadata.cacheHits / totalRequests * 100) : 0
return {
totalCached: metadata.totalCount || 0,
cacheHits: metadata.cacheHits || 0,
cacheMisses: metadata.cacheMisses || 0,
hitRate: Math.round(hitRate * 100) / 100,
lastCleanup: metadata.lastCleanup ? new Date(metadata.lastCleanup) : null
}
}
/**
* Clear all avatar cache data
* @returns {number} Number of entries cleared
*/
export function clearAllAvatarCache() {
const metadata = getAvatarCacheMetadata()
const handles = metadata.handles || []
handles.forEach(handle => {
const cacheKey = `${CACHE_PREFIX}${handle}`
dataCache.delete(cacheKey)
})
// Clear metadata
dataCache.delete(METADATA_KEY)
return handles.length
}
/**
* Preload avatars for a list of handles
* @param {Array<string>} handles - Handles to preload
* @param {Function} getAvatar - Avatar fetching function
* @returns {Promise<Map>} Map of handle -> avatar URL results
*/
export async function preloadAvatars(handles, getAvatar) {
const results = new Map()
const BATCH_SIZE = 3 // Smaller batch for preloading
for (let i = 0; i < handles.length; i += BATCH_SIZE) {
const batch = handles.slice(i, i + BATCH_SIZE)
const promises = batch.map(async (handle) => {
try {
const avatar = await getAvatar({ handle })
return { handle, avatar, success: true }
} catch (error) {
return { handle, avatar: null, success: false, error: error.message }
}
})
const batchResults = await Promise.all(promises)
batchResults.forEach(({ handle, avatar, success }) => {
results.set(handle, { avatar, success })
if (success) {
addCachedHandle(handle)
}
})
// Small delay between batches to avoid overwhelming the API
if (i + BATCH_SIZE < handles.length) {
await new Promise(resolve => setTimeout(resolve, 100))
}
}
return results
}
/**
* Validate cached avatar URLs
* Check if cached avatar URLs are still valid
* @param {number} sampleSize - Number of cached avatars to validate (default: 5)
* @returns {Promise<Object>} Validation results
*/
export async function validateCachedAvatars(sampleSize = 5) {
const metadata = getAvatarCacheMetadata()
const handles = metadata.handles || []
if (handles.length === 0) {
return { validCount: 0, invalidCount: 0, totalChecked: 0 }
}
// Sample random handles to check
const samplesToCheck = handles
.sort(() => Math.random() - 0.5)
.slice(0, sampleSize)
let validCount = 0
let invalidCount = 0
for (const handle of samplesToCheck) {
const cacheKey = `${CACHE_PREFIX}${handle}`
const avatarUrl = dataCache.get(cacheKey)
if (avatarUrl && typeof avatarUrl === 'string' && avatarUrl.startsWith('http')) {
try {
const response = await fetch(avatarUrl, { method: 'HEAD' })
if (response.ok) {
validCount++
} else {
invalidCount++
// Remove invalid cached avatar
dataCache.delete(cacheKey)
removeCachedHandle(handle)
}
} catch {
invalidCount++
// Remove invalid cached avatar
dataCache.delete(cacheKey)
removeCachedHandle(handle)
}
} else {
invalidCount++
// Remove invalid cache entry
dataCache.delete(cacheKey)
removeCachedHandle(handle)
}
}
return {
validCount,
invalidCount,
totalChecked: samplesToCheck.length,
validationRate: samplesToCheck.length > 0 ?
Math.round((validCount / samplesToCheck.length) * 100) : 0
}
}

View File

@@ -0,0 +1,147 @@
import { getPdsFromHandle, getApiConfig } from './pds.js'
import { logger } from './logger.js'
// Avatar取得の状態管理
const avatarCache = new Map()
const CACHE_DURATION = 30 * 60 * 1000 // 30分
// Avatar URLが有効かチェック
async function isAvatarValid(avatarUrl) {
if (!avatarUrl) return false
try {
const response = await fetch(avatarUrl, { method: 'HEAD' })
return response.ok
} catch (error) {
logger.warn('Avatar URL check failed:', error)
return false
}
}
// handleからDIDを取得
async function getDid(handle) {
try {
const pds = await getPdsFromHandle(handle)
const response = await fetch(`${pds}/xrpc/com.atproto.repo.describeRepo?repo=${handle}`)
const data = await response.json()
return data.did
} catch (error) {
logger.error('Failed to get DID for handle:', handle, error)
throw error
}
}
// DIDからプロフィール情報を取得
async function getProfile(did, handle) {
try {
// Determine which public API to use based on handle
const pds = await getPdsFromHandle(handle)
const apiConfig = getApiConfig(pds)
// Use the appropriate public API endpoint
const publicApiUrl = apiConfig.bsky
logger.log('Getting profile for DID:', did, 'using public API:', publicApiUrl)
const response = await fetch(`${publicApiUrl}/xrpc/app.bsky.actor.getProfile?actor=${did}`)
if (!response.ok) {
throw new Error(`Profile API error: ${response.status} ${response.statusText}`)
}
const data = await response.json()
logger.log('Profile data received:', data)
return data
} catch (error) {
logger.error('Failed to get profile for DID:', did, error)
throw error
}
}
// 新しいavatar URLを取得
async function fetchFreshAvatar(handle, did) {
const cacheKey = `${handle}:${did || 'no-did'}`
const cached = avatarCache.get(cacheKey)
// キャッシュチェック
if (cached && Date.now() - cached.timestamp < CACHE_DURATION) {
logger.log('Using cached avatar for:', handle)
return cached.avatar
}
try {
logger.log('Fetching fresh avatar for handle:', handle, 'with DID:', did)
// DIDが不明な場合は取得
let actualDid = did
if (!actualDid) {
logger.log('No DID provided, fetching from handle:', handle)
actualDid = await getDid(handle)
logger.log('Got DID from handle:', actualDid)
}
// プロフィール取得
const profile = await getProfile(actualDid, handle)
const avatarUrl = profile.avatar || null
// キャッシュに保存
avatarCache.set(cacheKey, {
avatar: avatarUrl,
timestamp: Date.now(),
profile: {
displayName: profile.displayName,
handle: profile.handle
}
})
logger.log('Fresh avatar fetched for:', handle, 'Avatar URL:', avatarUrl)
return avatarUrl
} catch (error) {
logger.error('Failed to fetch fresh avatar for:', handle, 'Error:', error)
return null
}
}
// メイン関数: avatarを取得recordから → 新規取得)
export async function getValidAvatar(record) {
const author = record?.value?.author
if (!author?.handle) {
logger.warn('No handle found in record author')
return null
}
const { handle, did, avatar: recordAvatar } = author
// 1. record内のavatarをチェック
if (recordAvatar) {
const isValid = await isAvatarValid(recordAvatar)
if (isValid) {
logger.log('Using avatar from record:', recordAvatar)
return recordAvatar
} else {
logger.log('Record avatar is broken, fetching fresh:', recordAvatar)
}
}
// 2. 新しいavatarを取得
return await fetchFreshAvatar(handle, did)
}
// キャッシュクリア
export function clearAvatarCache() {
avatarCache.clear()
logger.log('Avatar cache cleared')
}
// キャッシュ統計
export function getAvatarCacheStats() {
return {
size: avatarCache.size,
entries: Array.from(avatarCache.entries()).map(([key, value]) => ({
key,
avatar: value.avatar,
age: Date.now() - value.timestamp,
profile: value.profile
}))
}
}

63
oauth/src/utils/cache.js Normal file
View File

@@ -0,0 +1,63 @@
import { logger } from './logger.js'
class SimpleCache {
constructor(ttl = 30000) { // 30秒TTL
this.cache = new Map()
this.ttl = ttl
}
generateKey(...parts) {
return parts.filter(Boolean).join(':')
}
get(key) {
const item = this.cache.get(key)
if (!item) return null
if (Date.now() - item.timestamp > this.ttl) {
this.cache.delete(key)
return null
}
logger.log(`Cache hit: ${key}`)
return item.data
}
set(key, data) {
this.cache.set(key, {
data,
timestamp: Date.now()
})
logger.log(`Cache set: ${key}`)
}
clear() {
this.cache.clear()
logger.log('Cache cleared')
}
invalidatePattern(pattern) {
let deletedCount = 0
for (const key of this.cache.keys()) {
if (key.includes(pattern)) {
this.cache.delete(key)
deletedCount++
}
}
logger.log(`Cache invalidated: ${pattern} (${deletedCount} items)`)
}
getStats() {
return {
size: this.cache.size,
keys: Array.from(this.cache.keys())
}
}
}
export const dataCache = new SimpleCache()
// デバッグ用:開発環境でのみグローバルからアクセス可能にする
if (import.meta.env.DEV) {
window.dataCache = dataCache
}

View File

@@ -0,0 +1,49 @@
import { logger } from './logger.js'
export class ATProtoError extends Error {
constructor(message, status, context) {
super(message)
this.status = status
this.context = context
this.timestamp = new Date().toISOString()
}
}
export function getErrorMessage(error) {
if (!error) return '不明なエラー'
if (error.status === 400) {
return 'アカウントまたはレコードが見つかりません'
} else if (error.status === 401) {
return '認証が必要です。ログインしてください'
} else if (error.status === 403) {
return 'アクセス権限がありません'
} else if (error.status === 429) {
return 'アクセスが集中しています。しばらく待ってから再試行してください'
} else if (error.status === 500) {
return 'サーバーでエラーが発生しました'
} else if (error.message?.includes('fetch')) {
return 'ネットワーク接続を確認してください'
} else if (error.message?.includes('timeout')) {
return 'タイムアウトしました。再試行してください'
}
return `エラーが発生しました: ${error.message || '不明'}`
}
export function logError(error, context = 'Unknown') {
const errorInfo = {
context,
message: error.message,
status: error.status,
timestamp: new Date().toISOString(),
url: window.location.href
}
logger.error(`[ATProto Error] ${context}:`, errorInfo)
// 本番環境では外部ログサービスに送信することも可能
// if (import.meta.env.PROD) {
// sendToLogService(errorInfo)
// }
}

82
oauth/src/utils/logger.js Normal file
View File

@@ -0,0 +1,82 @@
// Logger utility with environment-based control
class Logger {
constructor() {
this.isDev = import.meta.env.DEV || false
this.debugEnabled = import.meta.env.VITE_ENABLE_DEBUG === 'true'
this.isEnabled = this.isDev && this.debugEnabled // Enable only in dev AND when debug flag is true
}
log(...args) {
if (this.isEnabled) {
console.log(...args)
}
}
error(...args) {
if (this.isEnabled) {
console.error(...args)
}
}
warn(...args) {
if (this.isEnabled) {
console.warn(...args)
}
}
info(...args) {
if (this.isEnabled) {
console.info(...args)
}
}
// グループログ
group(label) {
if (this.isEnabled) {
console.group(label)
}
}
groupEnd() {
if (this.isEnabled) {
console.groupEnd()
}
}
// テーブル表示
table(data) {
if (this.isEnabled) {
console.table(data)
}
}
// 時間計測
time(label) {
if (this.isEnabled) {
console.time(label)
}
}
timeEnd(label) {
if (this.isEnabled) {
console.timeEnd(label)
}
}
// ログを有効/無効にする
enable() {
this.isEnabled = true
}
disable() {
this.isEnabled = false
}
}
// シングルトンインスタンス
export const logger = new Logger()
// 開発環境でのみグローバルアクセス可能にする
if (import.meta.env.DEV && import.meta.env.VITE_ENABLE_DEBUG === 'true') {
window._logger = logger
}

View File

@@ -1,138 +0,0 @@
/**
* OAuth dynamic endpoint handlers
*/
import { OAuthKeyManager, generateClientMetadata } from './oauth-keys';
export class OAuthEndpointHandler {
/**
* Initialize OAuth endpoint handlers
*/
static init() {
// Intercept requests to client-metadata.json
this.setupClientMetadataHandler();
// Intercept requests to .well-known/jwks.json
this.setupJWKSHandler();
}
private static setupClientMetadataHandler() {
// Override fetch for client-metadata.json requests
const originalFetch = window.fetch;
window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input.toString();
// Only intercept local OAuth endpoints
try {
const urlObj = new URL(url, window.location.origin);
// Only intercept requests to the same origin
if (urlObj.origin !== window.location.origin) {
// Pass through external API calls unchanged
return originalFetch(input, init);
}
// Handle local OAuth endpoints
if (urlObj.pathname.endsWith('/client-metadata.json')) {
const metadata = generateClientMetadata();
return new Response(JSON.stringify(metadata, null, 2), {
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
}
});
}
if (urlObj.pathname.endsWith('/.well-known/jwks.json')) {
try {
const jwks = await OAuthKeyManager.getJWKS();
return new Response(JSON.stringify(jwks, null, 2), {
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
}
});
} catch (error) {
return new Response(JSON.stringify({ error: 'Failed to generate JWKS' }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
}
} catch (e) {
// If URL parsing fails, pass through to original fetch
}
// Pass through all other requests
return originalFetch(input, init);
};
}
private static setupJWKSHandler() {
// This is handled in the fetch override above
}
/**
* Generate a proper client assertion JWT for token requests
*/
static async generateClientAssertion(tokenEndpoint: string): Promise<string> {
const now = Math.floor(Date.now() / 1000);
const clientId = generateClientMetadata().client_id;
const header = {
alg: 'ES256',
typ: 'JWT',
kid: 'ai-card-oauth-key-1'
};
const payload = {
iss: clientId,
sub: clientId,
aud: tokenEndpoint,
iat: now,
exp: now + 300, // 5 minutes
jti: crypto.randomUUID()
};
return await OAuthKeyManager.signJWT(header, payload);
}
}
/**
* Service Worker alternative for intercepting requests
* (This is a more robust solution for production)
*/
export function registerOAuthServiceWorker() {
if ('serviceWorker' in navigator) {
const swCode = `
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
if (url.pathname.endsWith('/client-metadata.json')) {
event.respondWith(
new Response(JSON.stringify({
client_id: url.origin + '/client-metadata.json',
client_name: 'ai.card',
client_uri: url.origin,
redirect_uris: [url.origin + '/oauth/callback'],
response_types: ['code'],
grant_types: ['authorization_code', 'refresh_token'],
token_endpoint_auth_method: 'private_key_jwt',
scope: 'atproto transition:generic',
subject_type: 'public',
application_type: 'web',
dpop_bound_access_tokens: true,
jwks_uri: url.origin + '/.well-known/jwks.json'
}, null, 2), {
headers: { 'Content-Type': 'application/json' }
})
);
}
});
`;
const blob = new Blob([swCode], { type: 'application/javascript' });
const swUrl = URL.createObjectURL(blob);
}
}

View File

@@ -1,181 +0,0 @@
/**
* OAuth JWKS key generation and management
*/
export interface JWK {
kty: string;
crv: string;
x: string;
y: string;
d?: string;
use: string;
kid: string;
alg: string;
}
export interface JWKS {
keys: JWK[];
}
export class OAuthKeyManager {
private static keyPair: CryptoKeyPair | null = null;
private static jwks: JWKS | null = null;
/**
* Generate or retrieve existing ECDSA key pair for OAuth
*/
static async getKeyPair(): Promise<CryptoKeyPair> {
if (this.keyPair) {
return this.keyPair;
}
// Try to load from localStorage first
const storedKey = localStorage.getItem('oauth_private_key');
if (storedKey) {
try {
const keyData = JSON.parse(storedKey);
this.keyPair = await this.importKeyPair(keyData);
return this.keyPair;
} catch (error) {
localStorage.removeItem('oauth_private_key');
}
}
// Generate new key pair
this.keyPair = await window.crypto.subtle.generateKey(
{
name: 'ECDSA',
namedCurve: 'P-256',
},
true, // extractable
['sign', 'verify']
);
// Store private key for persistence
await this.storeKeyPair(this.keyPair);
return this.keyPair;
}
/**
* Get JWKS (JSON Web Key Set) for public key distribution
*/
static async getJWKS(): Promise<JWKS> {
if (this.jwks) {
return this.jwks;
}
const keyPair = await this.getKeyPair();
const publicKey = await window.crypto.subtle.exportKey('jwk', keyPair.publicKey);
this.jwks = {
keys: [
{
kty: publicKey.kty!,
crv: publicKey.crv!,
x: publicKey.x!,
y: publicKey.y!,
use: 'sig',
kid: 'ai-card-oauth-key-1',
alg: 'ES256'
}
]
};
return this.jwks;
}
/**
* Sign a JWT with the private key
*/
static async signJWT(header: any, payload: any): Promise<string> {
const keyPair = await this.getKeyPair();
const headerB64 = btoa(JSON.stringify(header)).replace(/=/g, '');
const payloadB64 = btoa(JSON.stringify(payload)).replace(/=/g, '');
const message = `${headerB64}.${payloadB64}`;
const signature = await window.crypto.subtle.sign(
{ name: 'ECDSA', hash: 'SHA-256' },
keyPair.privateKey,
new TextEncoder().encode(message)
);
const signatureB64 = btoa(String.fromCharCode(...new Uint8Array(signature)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
return `${message}.${signatureB64}`;
}
private static async storeKeyPair(keyPair: CryptoKeyPair): Promise<void> {
try {
const privateKey = await window.crypto.subtle.exportKey('jwk', keyPair.privateKey);
localStorage.setItem('oauth_private_key', JSON.stringify(privateKey));
} catch (error) {
}
}
private static async importKeyPair(keyData: any): Promise<CryptoKeyPair> {
const privateKey = await window.crypto.subtle.importKey(
'jwk',
keyData,
{ name: 'ECDSA', namedCurve: 'P-256' },
true,
['sign']
);
// Derive public key from private key
const publicKeyData = { ...keyData };
delete publicKeyData.d; // Remove private component
const publicKey = await window.crypto.subtle.importKey(
'jwk',
publicKeyData,
{ name: 'ECDSA', namedCurve: 'P-256' },
true,
['verify']
);
return { privateKey, publicKey };
}
/**
* Clear stored keys (for testing/reset)
*/
static clearKeys(): void {
localStorage.removeItem('oauth_private_key');
this.keyPair = null;
this.jwks = null;
}
}
/**
* Generate dynamic client metadata based on current URL
*/
export function generateClientMetadata(): any {
// Use environment variables if available, fallback to current origin
const host = import.meta.env.VITE_APP_HOST || window.location.origin;
const clientId = import.meta.env.VITE_OAUTH_CLIENT_ID || `${host}/client-metadata.json`;
const redirectUri = import.meta.env.VITE_OAUTH_REDIRECT_URI || `${host}/oauth/callback`;
return {
client_id: clientId,
client_name: 'ai.card',
client_uri: host,
logo_uri: `${host}/favicon.ico`,
tos_uri: `${host}/terms`,
policy_uri: `${host}/privacy`,
redirect_uris: [redirectUri, host],
response_types: ['code'],
grant_types: ['authorization_code', 'refresh_token'],
token_endpoint_auth_method: 'private_key_jwt',
token_endpoint_auth_signing_alg: 'ES256',
scope: 'atproto transition:generic',
subject_type: 'public',
application_type: 'web',
dpop_bound_access_tokens: true,
jwks_uri: `${host}/.well-known/jwks.json`
};
}

View File

@@ -1,348 +0,0 @@
// PDS Detection and API URL mapping utilities
import { isValidDid, isValidHandle } from './validation';
export interface NetworkConfig {
pdsApi: string;
plcApi: string;
bskyApi: string;
webUrl: string;
}
// Detect PDS from handle
export function detectPdsFromHandle(handle: string): string {
// Get allowed handles from environment
const allowedHandlesStr = import.meta.env.VITE_ATPROTO_HANDLE_LIST || '[]';
let allowedHandles: string[] = [];
try {
allowedHandles = JSON.parse(allowedHandlesStr);
} catch {
allowedHandles = [];
}
// Get configured PDS from environment
const configuredPds = import.meta.env.VITE_ATPROTO_PDS || 'syu.is';
// Check if handle is in allowed list
if (allowedHandles.includes(handle)) {
return configuredPds;
}
// Check if handle ends with .syu.is or .syui.ai
if (handle.endsWith('.syu.is') || handle.endsWith('.syui.ai')) {
return 'syu.is';
}
// Check if handle ends with .bsky.social or .bsky.app
if (handle.endsWith('.bsky.social') || handle.endsWith('.bsky.app')) {
return 'bsky.social';
}
// Default to Bluesky for unknown domains
return 'bsky.social';
}
// Map PDS endpoint to network configuration
export function getNetworkConfigFromPdsEndpoint(pdsEndpoint: string): NetworkConfig {
try {
const url = new URL(pdsEndpoint);
const hostname = url.hostname;
// Map based on actual PDS endpoint
if (hostname === 'syu.is') {
return {
pdsApi: 'https://syu.is', // PDS API (repo operations)
plcApi: 'https://plc.syu.is', // PLC directory
bskyApi: 'https://bsky.syu.is', // Bluesky API (getProfile, etc.)
webUrl: 'https://web.syu.is' // Web interface
};
} else if (hostname.includes('bsky.network') || hostname === 'bsky.social' || hostname.includes('host.bsky.network')) {
// All Bluesky infrastructure (including *.host.bsky.network)
return {
pdsApi: pdsEndpoint, // Use actual PDS endpoint (e.g., shiitake.us-east.host.bsky.network)
plcApi: 'https://plc.directory', // Standard PLC directory
bskyApi: 'https://public.api.bsky.app', // Bluesky public API (NOT PDS)
webUrl: 'https://bsky.app' // Bluesky web interface
};
} else {
// Unknown PDS, assume Bluesky-compatible but use PDS for repo operations
return {
pdsApi: pdsEndpoint, // Use actual PDS for repo ops
plcApi: 'https://plc.directory', // Default PLC
bskyApi: 'https://public.api.bsky.app', // Default to Bluesky API
webUrl: 'https://bsky.app' // Default web interface
};
}
} catch (error) {
// Fallback for invalid URLs
return {
pdsApi: 'https://bsky.social',
plcApi: 'https://plc.directory',
bskyApi: 'https://public.api.bsky.app',
webUrl: 'https://bsky.app'
};
}
}
// Legacy function for backwards compatibility
export function getNetworkConfig(pds: string): NetworkConfig {
// This now assumes pds is a hostname
return getNetworkConfigFromPdsEndpoint(`https://${pds}`);
}
// Get appropriate API URL for a user based on their handle
export function getApiUrlForUser(handle: string): string {
const pds = detectPdsFromHandle(handle);
const config = getNetworkConfig(pds);
return config.bskyApi;
}
// Resolve handle/DID to actual PDS endpoint using PLC API first
export async function resolvePdsFromRepo(handleOrDid: string): Promise<{ pds: string; did: string; handle: string }> {
// Validate input
if (!handleOrDid || (!isValidDid(handleOrDid) && !isValidHandle(handleOrDid))) {
throw new Error(`Invalid identifier: ${handleOrDid}`);
}
let targetDid = handleOrDid;
let targetHandle = handleOrDid;
// If handle provided, resolve to DID first using identity.resolveHandle
if (!handleOrDid.startsWith('did:')) {
try {
// Try multiple endpoints for handle resolution
const resolveEndpoints = ['https://public.api.bsky.app', 'https://bsky.syu.is', 'https://syu.is'];
let resolved = false;
for (const endpoint of resolveEndpoints) {
try {
const resolveResponse = await fetch(`${endpoint}/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handleOrDid)}`);
if (resolveResponse.ok) {
const resolveData = await resolveResponse.json();
targetDid = resolveData.did;
resolved = true;
break;
}
} catch (error) {
continue;
}
}
if (!resolved) {
throw new Error('Handle resolution failed from all endpoints');
}
} catch (error) {
throw new Error(`Failed to resolve handle ${handleOrDid} to DID: ${error}`);
}
}
// First, try PLC API to get the authoritative DID document
const plcApis = ['https://plc.directory', 'https://plc.syu.is'];
for (const plcApi of plcApis) {
try {
const plcResponse = await fetch(`${plcApi}/${targetDid}`);
if (plcResponse.ok) {
const didDocument = await plcResponse.json();
// Find PDS service in DID document
const pdsService = didDocument.service?.find((s: any) =>
s.id === '#atproto_pds' || s.type === 'AtprotoPersonalDataServer'
);
if (pdsService && pdsService.serviceEndpoint) {
return {
pds: pdsService.serviceEndpoint,
did: targetDid,
handle: targetHandle
};
}
}
} catch (error) {
continue;
}
}
// Fallback: use com.atproto.repo.describeRepo to get PDS from known PDS endpoints
const pdsEndpoints = ['https://bsky.social', 'https://syu.is'];
for (const pdsEndpoint of pdsEndpoints) {
try {
const response = await fetch(`${pdsEndpoint}/xrpc/com.atproto.repo.describeRepo?repo=${encodeURIComponent(targetDid)}`);
if (response.ok) {
const data = await response.json();
// Extract PDS from didDoc.service
const services = data.didDoc?.service || [];
const pdsService = services.find((s: any) =>
s.id === '#atproto_pds' || s.type === 'AtprotoPersonalDataServer'
);
if (pdsService) {
return {
pds: pdsService.serviceEndpoint,
did: data.did || targetDid,
handle: data.handle || targetHandle
};
}
}
} catch (error) {
continue;
}
}
throw new Error(`Failed to resolve PDS for ${handleOrDid} from any endpoint`);
}
// Resolve DID to actual PDS endpoint using com.atproto.repo.describeRepo
export async function resolvePdsFromDid(did: string): Promise<string> {
const resolved = await resolvePdsFromRepo(did);
return resolved.pds;
}
// Enhanced resolve handle to DID with proper PDS detection
export async function resolveHandleToDid(handle: string): Promise<{ did: string; pds: string }> {
try {
// First, try to resolve the handle to DID using multiple methods
const apiUrl = getApiUrlForUser(handle);
const response = await fetch(`${apiUrl}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(handle)}`);
if (!response.ok) {
throw new Error(`Failed to resolve handle: ${response.status}`);
}
const data = await response.json();
const did = data.did;
// Now resolve the actual PDS from the DID
const actualPds = await resolvePdsFromDid(did);
return {
did: did,
pds: actualPds
};
} catch (error) {
// Failed to resolve handle
// Fallback to handle-based detection
const fallbackPds = detectPdsFromHandle(handle);
throw error;
}
}
// Get profile using appropriate API for the user with accurate PDS resolution
export async function getProfileForUser(handleOrDid: string, knownPdsEndpoint?: string): Promise<any> {
try {
let apiUrl: string;
if (knownPdsEndpoint) {
// If we already know the user's PDS endpoint, use it directly
const config = getNetworkConfigFromPdsEndpoint(knownPdsEndpoint);
apiUrl = config.bskyApi;
} else {
// Resolve the user's actual PDS using describeRepo
try {
const resolved = await resolvePdsFromRepo(handleOrDid);
const config = getNetworkConfigFromPdsEndpoint(resolved.pds);
apiUrl = config.bskyApi;
} catch {
// Fallback to handle-based detection
apiUrl = getApiUrlForUser(handleOrDid);
}
}
const response = await fetch(`${apiUrl}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(handleOrDid)}`);
if (!response.ok) {
throw new Error(`Failed to get profile: ${response.status}`);
}
return await response.json();
} catch (error) {
// Failed to get profile
// Final fallback: try with default Bluesky API
try {
const response = await fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(handleOrDid)}`);
if (response.ok) {
return await response.json();
}
} catch {
// Ignore fallback errors
}
throw error;
}
}
// Test and verify PDS detection methods
export async function verifyPdsDetection(handleOrDid: string): Promise<void> {
try {
// Method 1: com.atproto.repo.describeRepo (PRIMARY METHOD)
try {
const resolved = await resolvePdsFromRepo(handleOrDid);
const config = getNetworkConfigFromPdsEndpoint(resolved.pds);
} catch (error) {
// describeRepo failed
}
// Method 2: com.atproto.identity.resolveHandle (for comparison)
if (!handleOrDid.startsWith('did:')) {
try {
const resolveResponse = await fetch(`https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handleOrDid)}`);
if (resolveResponse.ok) {
const resolveData = await resolveResponse.json();
}
} catch (error) {
// Error resolving handle
}
}
// Method 3: PLC Directory lookup (if we have a DID)
let targetDid = handleOrDid;
if (!handleOrDid.startsWith('did:')) {
try {
const profile = await getProfileForUser(handleOrDid);
targetDid = profile.did;
} catch {
return;
}
}
try {
const plcResponse = await fetch(`https://plc.directory/${targetDid}`);
if (plcResponse.ok) {
const didDocument = await plcResponse.json();
// Find PDS service
const pdsService = didDocument.service?.find((s: any) =>
s.id === '#atproto_pds' || s.type === 'AtprotoPersonalDataServer'
);
if (pdsService) {
// Try to detect if this is a known network
const pdsUrl = pdsService.serviceEndpoint;
const hostname = new URL(pdsUrl).hostname;
const detectedNetwork = detectPdsFromHandle(`user.${hostname}`);
const networkConfig = getNetworkConfig(hostname);
}
}
} catch (error) {
// Error fetching from PLC directory
}
// Method 4: Our enhanced resolution
try {
if (handleOrDid.startsWith('did:')) {
const pdsEndpoint = await resolvePdsFromDid(handleOrDid);
} else {
const resolved = await resolveHandleToDid(handleOrDid);
}
} catch (error) {
// Enhanced resolution failed
}
} catch (error) {
// Overall verification failed
}
}

36
oauth/src/utils/pds.js Normal file
View File

@@ -0,0 +1,36 @@
import { env } from '../config/env.js'
// PDS判定からAPI設定を取得
export function getApiConfig(pds) {
if (pds.includes(env.pds)) {
return {
pds: `https://${env.pds}`,
bsky: `https://bsky.${env.pds}`,
plc: `https://plc.${env.pds}`,
web: `https://web.${env.pds}`
}
}
return {
pds: pds.startsWith('http') ? pds : `https://${pds}`,
bsky: 'https://public.api.bsky.app',
plc: 'https://plc.directory',
web: 'https://bsky.app'
}
}
// handleがsyu.is系かどうか判定
export function isSyuIsHandle(handle) {
return env.handleList.includes(handle) || handle.endsWith(`.${env.pds}`)
}
// handleからPDS取得
export async function getPdsFromHandle(handle) {
const initialPds = isSyuIsHandle(handle)
? `https://${env.pds}`
: 'https://bsky.social'
const data = await fetch(`${initialPds}/xrpc/com.atproto.repo.describeRepo?repo=${handle}`)
.then(res => res.json())
return data.didDoc?.service?.[0]?.serviceEndpoint || initialPds
}

View File

@@ -1,21 +0,0 @@
// Validation utilities for atproto identifiers
export function isValidDid(did: string): boolean {
if (!did || typeof did !== 'string') return false;
// Basic DID format: did:method:identifier
const didRegex = /^did:[a-z]+:[a-zA-Z0-9._%-]+$/;
return didRegex.test(did);
}
export function isValidHandle(handle: string): boolean {
if (!handle || typeof handle !== 'string') return false;
// Basic handle format: subdomain.domain.tld
const handleRegex = /^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/;
return handleRegex.test(handle);
}
export function isValidAtprotoIdentifier(identifier: string): boolean {
return isValidDid(identifier) || isValidHandle(identifier);
}