diff --git a/apps/twisted/.env.example b/apps/twisted/.env.example index e53c663..ad64629 100644 --- a/apps/twisted/.env.example +++ b/apps/twisted/.env.example @@ -1 +1,6 @@ -VITE_TWISTER_API_BASE_URL=http://localhost:8080 +VITE_TWISTER_API_BASE_URL=http://127.0.0.1:8080 + +# OAuth configuration +# VITE_OAUTH_CLIENT_ID must be a publicly accessible URL (use a tunnel for local dev) +# VITE_OAUTH_CLIENT_ID=https://your-tunnel.example.com/oauth/client-metadata.json +VITE_OAUTH_REDIRECT_URI=http://127.0.0.1:5173/oauth-callback diff --git a/apps/twisted/android/app/src/main/AndroidManifest.xml b/apps/twisted/android/app/src/main/AndroidManifest.xml index b06ddbf..ad2cc51 100644 --- a/apps/twisted/android/app/src/main/AndroidManifest.xml +++ b/apps/twisted/android/app/src/main/AndroidManifest.xml @@ -22,6 +22,13 @@ + + + + + + + UIViewControllerBasedStatusBarAppearance + CFBundleURLTypes + + + CFBundleURLSchemes + + io.ionic.starter + + + diff --git a/apps/twisted/package.json b/apps/twisted/package.json index 5d89f3c..bc9f717 100644 --- a/apps/twisted/package.json +++ b/apps/twisted/package.json @@ -15,6 +15,8 @@ "dependencies": { "@atcute/bluesky": "^3.3.0", "@atcute/client": "^4.2.1", + "@atcute/identity-resolver": "^1.2.2", + "@atcute/lexicons": "^1.2.9", "@atcute/oauth-browser-client": "^3.0.0", "@atcute/tangled": "^1.0.17", "@capacitor/android": "^8.2.0", diff --git a/apps/twisted/public/client-metadata.json b/apps/twisted/public/client-metadata.json new file mode 100644 index 0000000..51ff0b9 --- /dev/null +++ b/apps/twisted/public/client-metadata.json @@ -0,0 +1,14 @@ +{ + "client_id": "io.ionic.starter://client-metadata.json", + "client_name": "Twisted", + "client_uri": "https://tangled.org", + "logo_uri": "https://tangled.org/logo.png", + "tos_uri": "https://tangled.org/terms", + "policy_uri": "https://tangled.org/privacy", + "redirect_uris": ["io.ionic.starter://oauth-callback", "http://localhost:5173/oauth-callback"], + "scope": "atproto", + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + "application_type": "native", + "dpop_bound_access_tokens": true +} diff --git a/apps/twisted/src/app/router/index.ts b/apps/twisted/src/app/router/index.ts index c4c6523..d0c8a06 100644 --- a/apps/twisted/src/app/router/index.ts +++ b/apps/twisted/src/app/router/index.ts @@ -5,6 +5,8 @@ import TabsPage from "@/views/TabsPage.vue"; const routes: RouteRecordRaw[] = [ { path: "/", redirect: "/tabs/home" }, + { path: "/login", component: () => import("@/features/auth/LoginPage.vue") }, + { path: "/oauth-callback", component: () => import("@/features/auth/OAuthCallbackPage.vue") }, { path: "/tabs/", component: TabsPage, diff --git a/apps/twisted/src/core/auth/client.ts b/apps/twisted/src/core/auth/client.ts new file mode 100644 index 0000000..e0aeaa9 --- /dev/null +++ b/apps/twisted/src/core/auth/client.ts @@ -0,0 +1,38 @@ +import { Client } from "@atcute/client"; +import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; +import { useAuthStore } from "./store.ts"; + +let authClient: Client | null = null; +let currentUserAgent: OAuthUserAgent | null = null; + +export function getAuthClient(): Client | null { + const authStore = useAuthStore(); + const userAgent = authStore.getUserAgent(); + + if (!userAgent) { + authClient = null; + currentUserAgent = null; + return null; + } + + if (authClient && currentUserAgent === userAgent) { + return authClient; + } + + authClient = new Client({ handler: userAgent }); + currentUserAgent = userAgent; + return authClient; +} + +export function requireAuthClient(): Client { + const client = getAuthClient(); + if (!client) { + throw new Error("Authentication required"); + } + return client; +} + +export function clearAuthClient(): void { + authClient = null; + currentUserAgent = null; +} diff --git a/apps/twisted/src/core/auth/index.ts b/apps/twisted/src/core/auth/index.ts new file mode 100644 index 0000000..af3e967 --- /dev/null +++ b/apps/twisted/src/core/auth/index.ts @@ -0,0 +1,3 @@ +export { initializeOAuth, clientId, redirectUri } from "./oauth-config.ts"; +export { useAuthStore, type AuthState } from "./store.ts"; +export { getAuthClient, requireAuthClient, clearAuthClient } from "./client.ts"; diff --git a/apps/twisted/src/core/auth/oauth-config.ts b/apps/twisted/src/core/auth/oauth-config.ts new file mode 100644 index 0000000..e444607 --- /dev/null +++ b/apps/twisted/src/core/auth/oauth-config.ts @@ -0,0 +1,43 @@ +import { configureOAuth } from "@atcute/oauth-browser-client"; +import { + LocalActorResolver, + CompositeDidDocumentResolver, + CompositeHandleResolver, + PlcDidDocumentResolver, + WebDidDocumentResolver, + WellKnownHandleResolver, + DohJsonHandleResolver, +} from "@atcute/identity-resolver"; + +const isNative = typeof window !== "undefined" && !window.location.origin.startsWith("http"); +const baseUrl = isNative ? "io.ionic.starter://" : window.location.origin; +const clientId = + (!isNative && import.meta.env.VITE_OAUTH_CLIENT_ID?.trim()) || + `${baseUrl}/client-metadata.json`; +const redirectUri = + (!isNative && import.meta.env.VITE_OAUTH_REDIRECT_URI?.trim()) || + `${baseUrl}/oauth-callback`; + +const didResolver = new CompositeDidDocumentResolver({ + methods: { plc: new PlcDidDocumentResolver(), web: new WebDidDocumentResolver() }, +}); + +const handleResolver = new CompositeHandleResolver({ + strategy: "race", + methods: { + http: new WellKnownHandleResolver(), + dns: new DohJsonHandleResolver({ dohUrl: "https://cloudflare-dns.com/dns-query" }), + }, +}); + +const actorResolver = new LocalActorResolver({ didDocumentResolver: didResolver, handleResolver }); + +export function initializeOAuth(): void { + configureOAuth({ + metadata: { client_id: clientId, redirect_uri: redirectUri }, + identityResolver: actorResolver, + storageName: "twisted-oauth", + }); +} + +export { clientId, redirectUri }; diff --git a/apps/twisted/src/core/auth/store.ts b/apps/twisted/src/core/auth/store.ts new file mode 100644 index 0000000..f550c1e --- /dev/null +++ b/apps/twisted/src/core/auth/store.ts @@ -0,0 +1,136 @@ +import { defineStore } from "pinia"; +import { ref, computed, Ref } from "vue"; +import type { Session } from "@atcute/oauth-browser-client"; +import { getSession, deleteStoredSession, listStoredSessions, OAuthUserAgent } from "@atcute/oauth-browser-client"; +import { initializeOAuth } from "./oauth-config.js"; + +export type AuthState = "idle" | "restoring" | "authenticating" | "authenticated" | "error"; + +export const useAuthStore = defineStore("auth", () => { + const state = ref("idle"); + const session = ref(null); + const userAgent: Ref = ref(null); + const error = ref(null); + const accounts = ref<`did:${string}:${string}`[]>([]); + + const isAuthenticated = computed(() => state.value === "authenticated" && session.value !== null); + const did = computed(() => session.value?.info.sub ?? null); + const pds = computed(() => session.value?.info.aud ?? null); + + function initialize() { + initializeOAuth(); + accounts.value = listStoredSessions(); + } + + async function restoreSession(preferredDid?: `did:${string}:${string}`): Promise { + state.value = "restoring"; + error.value = null; + + try { + const dids = listStoredSessions(); + if (dids.length === 0) { + state.value = "idle"; + return false; + } + + const targetDid = preferredDid && dids.includes(preferredDid) ? preferredDid : dids[0]; + const restoredSession = await getSession(targetDid, { allowStale: true }); + + session.value = restoredSession; + userAgent.value = new OAuthUserAgent(restoredSession); + accounts.value = dids; + state.value = "authenticated"; + return true; + } catch (e) { + error.value = e instanceof Error ? e.message : "Failed to restore session"; + state.value = "error"; + return false; + } + } + + async function switchAccount(targetDid: `did:${string}:${string}`): Promise { + if (!accounts.value.includes(targetDid)) { + error.value = "Account not found"; + return false; + } + + return restoreSession(targetDid); + } + + function setSession(newSession: Session) { + session.value = newSession; + userAgent.value = new OAuthUserAgent(newSession); + state.value = "authenticated"; + error.value = null; + accounts.value = listStoredSessions(); + } + + async function refreshSession(): Promise { + if (!session.value) return false; + + try { + const refreshed = await getSession(session.value.info.sub, { noCache: true }); + session.value = refreshed; + userAgent.value = new OAuthUserAgent(refreshed); + return true; + } catch (e) { + error.value = e instanceof Error ? e.message : "Failed to refresh session"; + return false; + } + } + + async function logout(): Promise { + if (userAgent.value) { + try { + await userAgent.value.signOut(); + } catch { + if (session.value) { + deleteStoredSession(session.value.info.sub); + } + } + } else if (session.value) { + deleteStoredSession(session.value.info.sub); + } + + session.value = null; + userAgent.value = null; + state.value = "idle"; + error.value = null; + accounts.value = listStoredSessions(); + } + + async function logoutAll(): Promise { + const dids = listStoredSessions(); + for (const d of dids) { + deleteStoredSession(d); + } + session.value = null; + userAgent.value = null; + state.value = "idle"; + error.value = null; + accounts.value = []; + } + + function getUserAgent(): OAuthUserAgent | null { + return userAgent.value; + } + + return { + state, + session, + userAgent, + error, + accounts, + isAuthenticated, + did, + pds, + initialize, + restoreSession, + switchAccount, + setSession, + refreshSession, + logout, + logoutAll, + getUserAgent, + }; +}); diff --git a/apps/twisted/src/features/auth/LoginPage.vue b/apps/twisted/src/features/auth/LoginPage.vue new file mode 100644 index 0000000..d82a47b --- /dev/null +++ b/apps/twisted/src/features/auth/LoginPage.vue @@ -0,0 +1,185 @@ + + + + + diff --git a/apps/twisted/src/features/auth/OAuthCallbackPage.vue b/apps/twisted/src/features/auth/OAuthCallbackPage.vue new file mode 100644 index 0000000..029f65c --- /dev/null +++ b/apps/twisted/src/features/auth/OAuthCallbackPage.vue @@ -0,0 +1,81 @@ + + + + + diff --git a/apps/twisted/src/features/profile/ProfilePage.vue b/apps/twisted/src/features/profile/ProfilePage.vue index 4bf2b5a..ec80c0f 100644 --- a/apps/twisted/src/features/profile/ProfilePage.vue +++ b/apps/twisted/src/features/profile/ProfilePage.vue @@ -2,113 +2,595 @@ - Profile + {{ profile?.handle || "Profile" }} + + + + + + + + - - - Profile - - - -