diff --git a/package-lock.json b/package-lock.json index d6c924b..984460c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.1", "dependencies": { "@atproto/api": "^0.19.5", + "@atproto/oauth-client-browser": "^0.3.41", "hls.js": "^1.6.15" }, "devDependencies": { @@ -22,6 +23,73 @@ "vite": "^7.2.6" } }, + "node_modules/@atproto-labs/did-resolver": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/@atproto-labs/did-resolver/-/did-resolver-0.2.6.tgz", + "integrity": "sha512-2K1bC04nI2fmgNcvof+yA28IhGlpWn2JKYlPa7To9JTKI45FINCGkQSGiL2nyXlyzDJJ34fZ1aq6/IRFIOIiqg==", + "license": "MIT", + "dependencies": { + "@atproto-labs/fetch": "0.2.3", + "@atproto-labs/pipe": "0.1.1", + "@atproto-labs/simple-store": "0.3.0", + "@atproto-labs/simple-store-memory": "0.1.4", + "@atproto/did": "0.3.0", + "zod": "^3.23.8" + } + }, + "node_modules/@atproto-labs/fetch": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@atproto-labs/fetch/-/fetch-0.2.3.tgz", + "integrity": "sha512-NZtbJOCbxKUFRFKMpamT38PUQMY0hX0p7TG5AEYOPhZKZEP7dHZ1K2s1aB8MdVH0qxmqX7nQleNrrvLf09Zfdw==", + "license": "MIT", + "dependencies": { + "@atproto-labs/pipe": "0.1.1" + } + }, + "node_modules/@atproto-labs/handle-resolver": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@atproto-labs/handle-resolver/-/handle-resolver-0.3.6.tgz", + "integrity": "sha512-qnSTXvOBNj1EHhp2qTWSX8MS5q3AwYU5LKlt5fBvSbCjgmTr2j0URHCv+ydrwO55KvsojIkTMgeMOh4YuY4fCA==", + "license": "MIT", + "dependencies": { + "@atproto-labs/simple-store": "0.3.0", + "@atproto-labs/simple-store-memory": "0.1.4", + "@atproto/did": "0.3.0", + "zod": "^3.23.8" + } + }, + "node_modules/@atproto-labs/identity-resolver": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@atproto-labs/identity-resolver/-/identity-resolver-0.3.6.tgz", + "integrity": "sha512-qoWqBDRobln0NR8L8dQjSp79E0chGkBhibEgxQa2f9WD+JbJdjQ0YvwwO5yeQn05pJoJmAwmI2wyJ45zjU7aWg==", + "license": "MIT", + "dependencies": { + "@atproto-labs/did-resolver": "0.2.6", + "@atproto-labs/handle-resolver": "0.3.6" + } + }, + "node_modules/@atproto-labs/pipe": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@atproto-labs/pipe/-/pipe-0.1.1.tgz", + "integrity": "sha512-hdNw2oUs2B6BN1lp+32pF7cp8EMKuIN5Qok2Vvv/aOpG/3tNSJ9YkvfI0k6Zd188LeDDYRUpYpxcoFIcGH/FNg==", + "license": "MIT" + }, + "node_modules/@atproto-labs/simple-store": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@atproto-labs/simple-store/-/simple-store-0.3.0.tgz", + "integrity": "sha512-nOb6ONKBRJHRlukW1sVawUkBqReLlLx6hT35VS3imaNPwiXDxLnTK7lxw3Lrl9k5yugSBDQAkZAq3MPTEFSUBQ==", + "license": "MIT" + }, + "node_modules/@atproto-labs/simple-store-memory": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/@atproto-labs/simple-store-memory/-/simple-store-memory-0.1.4.tgz", + "integrity": "sha512-3mKY4dP8I7yKPFj9VKpYyCRzGJOi5CEpOLPlRhoJyLmgs3J4RzDrjn323Oakjz2Aj2JzRU/AIvWRAZVhpYNJHw==", + "license": "MIT", + "dependencies": { + "@atproto-labs/simple-store": "0.3.0", + "lru-cache": "^10.2.0" + } + }, "node_modules/@atproto/api": { "version": "0.19.5", "resolved": "https://registry.npmjs.org/@atproto/api/-/api-0.19.5.tgz", @@ -50,6 +118,46 @@ "zod": "^3.23.8" } }, + "node_modules/@atproto/did": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@atproto/did/-/did-0.3.0.tgz", + "integrity": "sha512-raUPzUGegtW/6OxwCmM8bhZvuIMzxG5t9oWsth6Tp91Kb5fTnHV2h/KKNF1C82doeA4BdXCErTyg7ISwLbQkzA==", + "license": "MIT", + "dependencies": { + "zod": "^3.23.8" + } + }, + "node_modules/@atproto/jwk": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@atproto/jwk/-/jwk-0.6.0.tgz", + "integrity": "sha512-bDoJPvt7TrQVi/rBfBrSSpGykhtIriKxeYCYQTiPRKFfyRhbgpElF0wPXADjIswnbzZdOwbY63az4E/CFVT3Tw==", + "license": "MIT", + "dependencies": { + "multiformats": "^9.9.0", + "zod": "^3.23.8" + } + }, + "node_modules/@atproto/jwk-jose": { + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/@atproto/jwk-jose/-/jwk-jose-0.1.11.tgz", + "integrity": "sha512-i4Fnr2sTBYmMmHXl7NJh8GrCH+tDQEVWrcDMDnV5DjJfkgT17wIqvojIw9SNbSL4Uf0OtfEv6AgG0A+mgh8b5Q==", + "license": "MIT", + "dependencies": { + "@atproto/jwk": "0.6.0", + "jose": "^5.2.0" + } + }, + "node_modules/@atproto/jwk-webcrypto": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@atproto/jwk-webcrypto/-/jwk-webcrypto-0.2.0.tgz", + "integrity": "sha512-UmgRrrEAkWvxwhlwe30UmDOdTEFidlIzBC7C3cCbeJMcBN1x8B3KH+crXrsTqfWQBG58mXgt8wgSK3Kxs2LhFg==", + "license": "MIT", + "dependencies": { + "@atproto/jwk": "0.6.0", + "@atproto/jwk-jose": "0.1.11", + "zod": "^3.23.8" + } + }, "node_modules/@atproto/lex-data": { "version": "0.0.14", "resolved": "https://registry.npmjs.org/@atproto/lex-data/-/lex-data-0.0.14.tgz", @@ -85,6 +193,55 @@ "zod": "^3.23.8" } }, + "node_modules/@atproto/oauth-client": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@atproto/oauth-client/-/oauth-client-0.6.0.tgz", + "integrity": "sha512-F7ZTKzFptXgyihMkd7QTdRSkrh4XqrS+qTw+V81k5Q6Bh3MB1L3ypvfSJ6v7SSUJa6XxoZYJTCahHC1e+ndE6Q==", + "license": "MIT", + "dependencies": { + "@atproto-labs/did-resolver": "^0.2.6", + "@atproto-labs/fetch": "^0.2.3", + "@atproto-labs/handle-resolver": "^0.3.6", + "@atproto-labs/identity-resolver": "^0.3.6", + "@atproto-labs/simple-store": "^0.3.0", + "@atproto-labs/simple-store-memory": "^0.1.4", + "@atproto/did": "^0.3.0", + "@atproto/jwk": "^0.6.0", + "@atproto/oauth-types": "^0.6.3", + "@atproto/xrpc": "^0.7.7", + "core-js": "^3", + "multiformats": "^9.9.0", + "zod": "^3.23.8" + } + }, + "node_modules/@atproto/oauth-client-browser": { + "version": "0.3.41", + "resolved": "https://registry.npmjs.org/@atproto/oauth-client-browser/-/oauth-client-browser-0.3.41.tgz", + "integrity": "sha512-4QTm8zPgm08vl53flrVmL+MS5IOhvWWctNZmEnPbvQ2t1ISw9Q5m815m2Sszi5ULMFjOqvT7lhKB7zQUn5gq5g==", + "license": "MIT", + "dependencies": { + "@atproto-labs/did-resolver": "^0.2.6", + "@atproto-labs/handle-resolver": "^0.3.6", + "@atproto-labs/simple-store": "^0.3.0", + "@atproto/did": "^0.3.0", + "@atproto/jwk": "^0.6.0", + "@atproto/jwk-webcrypto": "^0.2.0", + "@atproto/oauth-client": "^0.6.0", + "@atproto/oauth-types": "^0.6.3", + "core-js": "^3" + } + }, + "node_modules/@atproto/oauth-types": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/@atproto/oauth-types/-/oauth-types-0.6.3.tgz", + "integrity": "sha512-jdKuoPknJuh/WjI+mYk7agSbx9mNVMbS6Dr3k1z2YMY2oRiCQjxYBuo4MLKATbxj05nMQaZRWlHRUazoAu5Cng==", + "license": "MIT", + "dependencies": { + "@atproto/did": "^0.3.0", + "@atproto/jwk": "^0.6.0", + "zod": "^3.23.8" + } + }, "node_modules/@atproto/syntax": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.5.2.tgz", @@ -1172,6 +1329,17 @@ "node": ">= 0.6" } }, + "node_modules/core-js": { + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz", + "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==", + "hasInstallScript": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/deepmerge": { "version": "4.3.1", "dev": true, @@ -1287,6 +1455,15 @@ "integrity": "sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA==", "license": "MIT" }, + "node_modules/jose": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", + "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/kleur": { "version": "4.1.5", "dev": true, @@ -1300,6 +1477,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, "node_modules/magic-string": { "version": "0.30.21", "dev": true, diff --git a/package.json b/package.json index 05cd921..03faecb 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ }, "dependencies": { "@atproto/api": "^0.19.5", + "@atproto/oauth-client-browser": "^0.3.41", "hls.js": "^1.6.15" } } diff --git a/src/lib/FrogHeader.svelte b/src/lib/FrogHeader.svelte index c6cd382..6a94098 100644 --- a/src/lib/FrogHeader.svelte +++ b/src/lib/FrogHeader.svelte @@ -13,7 +13,7 @@
- { /* TODO: implement login */ }} /> +

vod frog

diff --git a/src/lib/LoginButton.svelte b/src/lib/LoginButton.svelte index 53d86c7..c07500b 100644 --- a/src/lib/LoginButton.svelte +++ b/src/lib/LoginButton.svelte @@ -1,19 +1,27 @@ -
- - - + + + {#if auth.error} +

{auth.error}

+ {/if} +{:else} + +{/if} diff --git a/src/lib/auth.svelte.ts b/src/lib/auth.svelte.ts new file mode 100644 index 0000000..65eb0a6 --- /dev/null +++ b/src/lib/auth.svelte.ts @@ -0,0 +1,146 @@ +/** + * AT Protocol OAuth client for browser-based authentication. + * + * Uses @atproto/oauth-client-browser with a static client-metadata.json. + * Scopes: atproto (base) + repo:sky.boo.vods.watchlist (write watchlist records). + * Reading public records is done via unauthenticated API calls. + */ + +import { BrowserOAuthClient, type OAuthSession } from '@atproto/oauth-client-browser'; + +const PROD_CLIENT_ID = 'https://vods.sky.boo/client-metadata.json'; + +function isDev(): boolean { + return typeof window !== 'undefined' && ( + window.location.hostname === 'localhost' || + window.location.hostname === '127.0.0.1' + ); +} + +/** Reactive auth state */ +let session: OAuthSession | null = $state(null); +let did: string | null = $state(null); +let handle: string | null = $state(null); +let avatar: string | null = $state(null); +let loading: boolean = $state(true); +let error: string | null = $state(null); + +let _client: BrowserOAuthClient | null = null; + +export function getAuthState() { + return { + get session() { return session; }, + get did() { return did; }, + get handle() { return handle; }, + get avatar() { return avatar; }, + get loading() { return loading; }, + get error() { return error; }, + }; +} + +/** Create or return the cached OAuth client */ +async function getClient(): Promise { + if (_client) return _client; + + if (isDev()) { + // In dev mode, use loopback client metadata with our scopes declared + const port = window.location.port || '5173'; + const redirect = `http://127.0.0.1:${port}/oauth/callback` as const; + _client = new BrowserOAuthClient({ + handleResolver: 'https://bsky.social', + clientMetadata: { + client_id: `http://localhost?redirect_uri=${encodeURIComponent(redirect)}&scope=${encodeURIComponent('atproto repo:sky.boo.vods.watchlist')}`, + client_name: 'Vod Frog (dev)', + client_uri: `http://localhost:${port}`, + redirect_uris: [redirect], + scope: 'atproto repo:sky.boo.vods.watchlist', + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + token_endpoint_auth_method: 'none', + application_type: 'native', + dpop_bound_access_tokens: true, + }, + }); + } else { + _client = await BrowserOAuthClient.load({ + clientId: PROD_CLIENT_ID, + handleResolver: 'https://bsky.social', + }); + } + + return _client; +} + +/** Initialize auth: restore existing session or process OAuth callback. */ +export async function initAuth(): Promise { + loading = true; + error = null; + + try { + const client = await getClient(); + const result = await client.init(); + + if (result?.session) { + await setSession(result.session); + } + } catch (e: any) { + console.error('Auth init failed:', e); + error = e.message || 'Auth initialization failed'; + } finally { + loading = false; + } +} + +/** Start the OAuth sign-in flow by redirecting to the user's PDS. */ +export async function signIn(input: string): Promise { + error = null; + + try { + const client = await getClient(); + await client.signIn(input, { + scope: 'atproto repo:sky.boo.vods.watchlist' + }); + // signIn will redirect the browser — execution won't continue past here + } catch (e: any) { + console.error('Sign in failed:', e); + error = e.message || 'Sign in failed'; + throw e; + } +} + +/** Sign out and revoke the current session. */ +export async function signOut(): Promise { + if (!did) return; + + try { + const client = await getClient(); + await client.revoke(did); + } catch (e: any) { + console.error('Sign out error:', e); + } finally { + session = null; + did = null; + handle = null; + avatar = null; + error = null; + } +} + +async function setSession(s: OAuthSession): Promise { + session = s; + did = s.did; + handle = s.did; // placeholder until resolved + + // Resolve DID → human-readable handle, then fetch profile for avatar + try { + const { resolveHandle, getProfile } = await import('./api'); + const resolved = await resolveHandle(s.did); + const h = resolved.startsWith('@') ? resolved.slice(1) : resolved; + handle = h; + + const profile = await getProfile(h); + if (profile.avatar) avatar = profile.avatar; + } catch { + // keep DID as fallback + } +} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 3e86337..9840887 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,9 +1,15 @@ diff --git a/src/routes/oauth/callback/+page.svelte b/src/routes/oauth/callback/+page.svelte new file mode 100644 index 0000000..e15b9ac --- /dev/null +++ b/src/routes/oauth/callback/+page.svelte @@ -0,0 +1,8 @@ + + diff --git a/static/client-metadata.json b/static/client-metadata.json new file mode 100644 index 0000000..426f774 --- /dev/null +++ b/static/client-metadata.json @@ -0,0 +1,13 @@ +{ + "client_id": "https://vods.sky.boo/client-metadata.json", + "client_name": "Vod Frog", + "client_uri": "https://vods.sky.boo", + "logo_uri": "https://vods.sky.boo/frogicon.png", + "redirect_uris": ["https://vods.sky.boo/oauth/callback"], + "scope": "atproto repo:sky.boo.vods.watchlist", + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + "token_endpoint_auth_method": "none", + "application_type": "web", + "dpop_bound_access_tokens": true +}