diff --git a/apps/server/README.md b/apps/server/README.md index 1e86ba0d0f..1d56422648 100644 --- a/apps/server/README.md +++ b/apps/server/README.md @@ -53,3 +53,7 @@ When the mobile dev server uses a non-localhost origin (for example `https://10. `ADDITIONAL_TRUSTED_ORIGINS=https://10.0.0.129:5273,https://198.18.0.1:5273` Restart the API server after changing this variable. + +## Local HTTPS API (reverse proxy; no TLS in Node) + +When the client runs on **https** (e.g. Vite `dev:https` or Capacitor) while Hono stays on **`http://127.0.0.1:3000`**, WebKit can treat **`https` → `http` API** calls as mixed content. Terminate TLS on a reverse proxy or tunnel (for example **frp**, Caddy, or nginx) that forwards to this API, then point **`API_SERVER_URL`** and the frontend **`VITE_SERVER_URL`** (or equivalent `SERVER_URL`) at that **https** public base URL. diff --git a/apps/server/src/libs/auth.ts b/apps/server/src/libs/auth.ts index de10e72efa..34d2580770 100644 --- a/apps/server/src/libs/auth.ts +++ b/apps/server/src/libs/auth.ts @@ -169,13 +169,16 @@ function buildTrustedClientSeeds(env: Env): TrustedClientSeed[] { }) // Capacitor mobile app — public client (no secret, PKCE only). - // Same reasoning as Web: native WebView cannot safely store secrets. + // Uses an app-owned callback scheme so ASWebAuthenticationSession can hand + // the authorization response back to iOS Pocket outside the WKWebView. + // Keep the Capacitor WebView callback until Android has its native launcher. clients.push({ clientId: OIDC_CLIENT_ID_POCKET, name: 'AIRI Stage Mobile', type: 'native', public: true, redirectUris: [ + 'airi-pocket://auth/callback', 'capacitor://localhost/auth/callback', 'ai.moeru.airi-pocket://links/auth/callback', ], diff --git a/apps/server/src/libs/env.ts b/apps/server/src/libs/env.ts index bf11d76274..8c263d9390 100644 --- a/apps/server/src/libs/env.ts +++ b/apps/server/src/libs/env.ts @@ -31,14 +31,21 @@ export function parseAdditionalTrustedOriginsEnv(raw: string): string[] { if (!entry) continue - let normalized: string + let parsed: URL try { - normalized = new URL(entry).origin + parsed = new URL(entry) } catch { throw new TypeError(`ADDITIONAL_TRUSTED_ORIGINS: invalid URL origin segment "${entry}"`) } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') + throw new TypeError(`ADDITIONAL_TRUSTED_ORIGINS: invalid URL origin segment "${entry}"`) + + const normalized = parsed.origin + if (normalized === 'null') + throw new TypeError(`ADDITIONAL_TRUSTED_ORIGINS: invalid URL origin segment "${entry}"`) + if (!seen.has(normalized)) { seen.add(normalized) out.push(normalized) diff --git a/apps/server/src/libs/request-auth.ts b/apps/server/src/libs/request-auth.ts index a06a68a9be..1e9e51b72d 100644 --- a/apps/server/src/libs/request-auth.ts +++ b/apps/server/src/libs/request-auth.ts @@ -81,12 +81,22 @@ function resolveTestAuthToken(env: Env, accessToken: string): RequestAuthSession let cachedJWKS: ReturnType | null = null +function localJwksUrl(env: Env): URL { + const host = env.HOST ?? '0.0.0.0' + const lookupHost = host === '0.0.0.0' || host === '127.0.0.1' + ? '127.0.0.1' + : host === '::' + ? '::1' + : host + const origin = lookupHost.includes(':') + ? `http://[${lookupHost}]:${env.PORT}` + : `http://${lookupHost}:${env.PORT}` + return new URL('/api/auth/jwks', origin) +} + function getJWKS(env: Env): ReturnType { - if (!cachedJWKS) { - cachedJWKS = createRemoteJWKSet( - new URL('/api/auth/jwks', env.API_SERVER_URL), - ) - } + if (!cachedJWKS) + cachedJWKS = createRemoteJWKSet(localJwksUrl(env)) return cachedJWKS } diff --git a/apps/server/src/libs/tests/auth.test.ts b/apps/server/src/libs/tests/auth.test.ts index 41d557990e..9607e3b787 100644 --- a/apps/server/src/libs/tests/auth.test.ts +++ b/apps/server/src/libs/tests/auth.test.ts @@ -103,6 +103,7 @@ describe('seedTrustedClients', () => { expect(pocketClient.public).toBe(true) expect(pocketClient.tokenEndpointAuthMethod).toBe('none') expect(pocketClient.redirectUris).toEqual([ + 'airi-pocket://auth/callback', 'capacitor://localhost/auth/callback', 'ai.moeru.airi-pocket://links/auth/callback', ]) diff --git a/apps/server/src/libs/tests/env.test.ts b/apps/server/src/libs/tests/env.test.ts index 4bf4a02520..4d0e6c6676 100644 --- a/apps/server/src/libs/tests/env.test.ts +++ b/apps/server/src/libs/tests/env.test.ts @@ -33,6 +33,11 @@ describe('parseAdditionalTrustedOriginsEnv', () => { it('throws on invalid segments', () => { expect(() => parseAdditionalTrustedOriginsEnv('not-a-url')).toThrow(/invalid URL origin segment/) }) + + it('rejects opaque and non-http(s) origins', () => { + expect(() => parseAdditionalTrustedOriginsEnv('javascript:alert(1)')).toThrow(/invalid URL origin segment/) + expect(() => parseAdditionalTrustedOriginsEnv('file:///tmp/a')).toThrow(/invalid URL origin segment/) + }) }) describe('parseEnv', () => { diff --git a/apps/server/src/libs/tests/request-auth.test.ts b/apps/server/src/libs/tests/request-auth.test.ts index dc15379392..ac91783a67 100644 --- a/apps/server/src/libs/tests/request-auth.test.ts +++ b/apps/server/src/libs/tests/request-auth.test.ts @@ -2,17 +2,20 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { resolveRequestAuth } from '../request-auth' -// Mock jose module vi.mock('jose', () => ({ createRemoteJWKSet: vi.fn(() => 'mock-jwks'), jwtVerify: vi.fn(), })) const { jwtVerify } = await import('jose') +const { createRemoteJWKSet } = await import('jose') const mockedJwtVerify = vi.mocked(jwtVerify) +const mockedCreateRemoteJWKSet = vi.mocked(createRemoteJWKSet) const mockEnv = { API_SERVER_URL: 'http://localhost:3000', + HOST: '0.0.0.0', + PORT: 3000, TEST_AUTH_TOKEN: '', TEST_AUTH_USER_ID: 'test-user', TEST_AUTH_USER_EMAIL: 'test@example.com', @@ -116,6 +119,7 @@ describe('resolveRequestAuth', () => { new Headers({ Authorization: 'Bearer eyJhbGciOiJSUzI1NiJ9.test.sig' }), ) + expect(mockedCreateRemoteJWKSet).toHaveBeenCalledWith(new URL('http://127.0.0.1:3000/api/auth/jwks')) expect(result).toEqual({ user, session: { @@ -242,4 +246,97 @@ describe('resolveRequestAuth', () => { expect(result).toBeNull() }) + + it.each([ + ['10.0.0.5', 'http://10.0.0.5:3000'], + ['localhost', 'http://localhost:3000'], + ])('fetches JWKS from HOST %s', async (host, origin) => { + vi.resetModules() + mockedCreateRemoteJWKSet.mockClear() + mockedJwtVerify.mockResolvedValue({ + payload: { + sub: 'user-1', + iss: `${origin}/api/auth`, + aud: origin, + iat: Math.floor(Date.now() / 1000), + exp: Math.floor(Date.now() / 1000) + 3600, + jti: 'jwt-token-id', + }, + protectedHeader: { alg: 'RS256' }, + key: {} as any, + } as any) + + const { resolveRequestAuth: resolveWithHost } = await import('../request-auth') + const auth = { + api: { + getSession: vi.fn().mockResolvedValue(null), + }, + $context: Promise.resolve({ + internalAdapter: { + findUserById: vi.fn().mockResolvedValue({ + id: 'user-1', + email: 'user@example.com', + name: 'User', + emailVerified: true, + image: null, + createdAt: new Date(), + updatedAt: new Date(), + }), + }, + }), + } + + await resolveWithHost( + auth as any, + { API_SERVER_URL: origin, HOST: host, PORT: 3000 } as any, + new Headers({ Authorization: 'Bearer eyJhbGciOiJSUzI1NiJ9.test.sig' }), + ) + + expect(mockedCreateRemoteJWKSet).toHaveBeenCalledWith(new URL(`${origin}/api/auth/jwks`)) + }) + + it.each(['::1', '::'])('fetches JWKS from bracketed IPv6 loopback when HOST is %s', async (host) => { + vi.resetModules() + mockedCreateRemoteJWKSet.mockClear() + mockedJwtVerify.mockResolvedValue({ + payload: { + sub: 'user-1', + iss: 'http://[::1]:3000/api/auth', + aud: 'http://[::1]:3000', + iat: Math.floor(Date.now() / 1000), + exp: Math.floor(Date.now() / 1000) + 3600, + jti: 'jwt-token-id', + }, + protectedHeader: { alg: 'RS256' }, + key: {} as any, + } as any) + + const { resolveRequestAuth: resolveWithHost } = await import('../request-auth') + const auth = { + api: { + getSession: vi.fn().mockResolvedValue(null), + }, + $context: Promise.resolve({ + internalAdapter: { + findUserById: vi.fn().mockResolvedValue({ + id: 'user-1', + email: 'user@example.com', + name: 'User', + emailVerified: true, + image: null, + createdAt: new Date(), + updatedAt: new Date(), + }), + }, + }), + } + + await resolveWithHost( + auth as any, + { API_SERVER_URL: 'http://[::1]:3000', HOST: host, PORT: 3000 } as any, + new Headers({ Authorization: 'Bearer eyJhbGciOiJSUzI1NiJ9.test.sig' }), + ) + + expect(mockedCreateRemoteJWKSet).toHaveBeenCalledWith(new URL('http://[::1]:3000/api/auth/jwks')) + }) }) diff --git a/apps/stage-pocket/ios/App/App.xcodeproj/project.pbxproj b/apps/stage-pocket/ios/App/App.xcodeproj/project.pbxproj index 0d79301cc9..fd647a2c7e 100644 --- a/apps/stage-pocket/ios/App/App.xcodeproj/project.pbxproj +++ b/apps/stage-pocket/ios/App/App.xcodeproj/project.pbxproj @@ -20,6 +20,7 @@ 504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; }; 504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; }; 50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; }; + A1B2C3D42F10000100AA0007 /* AiriNativeAuthPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D42F10000100AA0008 /* AiriNativeAuthPlugin.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -38,6 +39,7 @@ 504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = ""; }; 958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; }; + A1B2C3D42F10000100AA0008 /* AiriNativeAuthPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AiriNativeAuthPlugin.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -72,6 +74,7 @@ 504EC3061FED79650016851F /* App */ = { isa = PBXGroup; children = ( + A1B2C3D42F10000100AA0008 /* AiriNativeAuthPlugin.swift */, 29ABB4B92F03F2B400285F7F /* DevBridgeViewController.swift */, A1B2C3D42F10000100AA0002 /* HostWebSocketBridge.swift */, A1B2C3D42F10000100AA0004 /* URLSessionHostWebSocketSession.swift */, @@ -171,6 +174,7 @@ buildActionMask = 2147483647; files = ( 504EC3081FED79650016851F /* AppDelegate.swift in Sources */, + A1B2C3D42F10000100AA0007 /* AiriNativeAuthPlugin.swift in Sources */, 29ABB4BA2F03F2B400285F7F /* DevBridgeViewController.swift in Sources */, A1B2C3D42F10000100AA0001 /* HostWebSocketBridge.swift in Sources */, A1B2C3D42F10000100AA0003 /* URLSessionHostWebSocketSession.swift in Sources */, diff --git a/apps/stage-pocket/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/apps/stage-pocket/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 5b4bd2d48c..79e244e7df 100644 --- a/apps/stage-pocket/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/apps/stage-pocket/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "8765687701ea10de7b4fa5981471aaf26157c825c91c99a7f0e25bbdf71f12e6", + "originHash" : "ba7adc1d840bb82293a01f8c96aa62c3684c551cb85db27a67ce3aaa8af8101a", "pins" : [ { "identity" : "capacitor-swift-pm", "kind" : "remoteSourceControl", "location" : "https://github.com/ionic-team/capacitor-swift-pm.git", "state" : { - "revision" : "0e862e6ff13852a710c8a484180ca4d6a2cc9761", - "version" : "8.2.0" + "revision" : "f1a8fadf1437c23b825c818fb6509c9dbbae2f61", + "version" : "8.3.1" } }, { diff --git a/apps/stage-pocket/ios/App/App/AiriNativeAuthPlugin.swift b/apps/stage-pocket/ios/App/App/AiriNativeAuthPlugin.swift new file mode 100644 index 0000000000..f92893f9e5 --- /dev/null +++ b/apps/stage-pocket/ios/App/App/AiriNativeAuthPlugin.swift @@ -0,0 +1,77 @@ +import AuthenticationServices +import Capacitor +import Foundation +import UIKit + +@objc(AiriNativeAuthPlugin) +class AiriNativeAuthPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPresentationContextProviding { + let identifier = "AiriNativeAuthPlugin" + let jsName = "AiriNativeAuth" + + let pluginMethods: [CAPPluginMethod] = [ + CAPPluginMethod(name: "authenticate", returnType: CAPPluginReturnPromise) + ] + + private var session: ASWebAuthenticationSession? + + @objc func authenticate(_ call: CAPPluginCall) { + guard let urlString = call.getString("url"), + let callbackScheme = call.getString("callbackScheme") + else { + call.reject("MISSING_ARGUMENTS", "url and callbackScheme are required") + return + } + + guard let url = URL(string: urlString) else { + call.reject("INVALID_URL", "url must be a valid URL") + return + } + + DispatchQueue.main.async { + guard self.session == nil else { + call.reject("AUTH_IN_PROGRESS", "An authentication session is already in progress") + return + } + + let session = ASWebAuthenticationSession( + url: url, + callbackURLScheme: callbackScheme + ) { [weak self] callbackURL, error in + self?.session = nil + + if let callbackURL { + call.resolve(["callbackUrl": callbackURL.absoluteString]) + return + } + + if let authError = error as? ASWebAuthenticationSessionError, + authError.code == .canceledLogin { + call.reject("USER_CANCELLED", "The authentication session was cancelled") + return + } + + call.reject("AUTH_FAILED", error?.localizedDescription ?? "Authentication failed") + } + + session.presentationContextProvider = self + session.prefersEphemeralWebBrowserSession = false + self.session = session + + if !session.start() { + self.session = nil + call.reject("AUTH_START_FAILED", "Failed to start authentication session") + } + } + } + + func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { + if let window = bridge?.viewController?.view.window { + return window + } + + return UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap(\.windows) + .first { $0.isKeyWindow } ?? ASPresentationAnchor() + } +} diff --git a/apps/stage-pocket/ios/App/App/DevBridgeViewController.swift b/apps/stage-pocket/ios/App/App/DevBridgeViewController.swift index ed63c23fed..ed9004aaa2 100644 --- a/apps/stage-pocket/ios/App/App/DevBridgeViewController.swift +++ b/apps/stage-pocket/ios/App/App/DevBridgeViewController.swift @@ -14,6 +14,7 @@ class DevBridgeViewController: CAPBridgeViewController { override func capacitorDidLoad() { super.capacitorDidLoad() + bridge?.registerPluginInstance(AiriNativeAuthPlugin()) configureTransparentBackground() webView?.allowsBackForwardNavigationGestures = true installWebSocketBridge() @@ -99,6 +100,7 @@ extension DevBridgeViewController: WKNavigationDelegate { if let url = navigationAction.request.url { print("[DevBridge] Navigation request to: \(url.absoluteString)") } + decisionHandler(.allow) } @@ -106,7 +108,9 @@ extension DevBridgeViewController: WKNavigationDelegate { _ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation! ) { - print("[DevBridge] Started provisional navigation") + print( + "[DevBridge] didStartProvisional webView.url=\(webView.url?.absoluteString ?? "nil")" + ) } func webView( @@ -155,6 +159,13 @@ extension DevBridgeViewController: WKNavigationDelegate { "[DevBridge] Timeout error - check if Vite server is running and accessible." ) } + if nsError.code == -1200 { + print( + "[DevBridge] TLS failed (-1200). Prefer an https URL the device trusts " + + "(e.g. frp or another reverse proxy to the dev API), or use http for local Vite " + + "when policy allows; see apps/server/README.md (Local HTTPS API)." + ) + } } } diff --git a/apps/stage-pocket/ios/App/App/Info.plist b/apps/stage-pocket/ios/App/App/Info.plist index ed4385713d..33d7b39d43 100644 --- a/apps/stage-pocket/ios/App/App/Info.plist +++ b/apps/stage-pocket/ios/App/App/Info.plist @@ -18,6 +18,17 @@ $(PRODUCT_NAME) CFBundlePackageType APPL + CFBundleURLTypes + + + CFBundleURLName + ai.moeru.airi-pocket.auth + CFBundleURLSchemes + + airi-pocket + + + CFBundleShortVersionString $(MARKETING_VERSION) CFBundleVersion diff --git a/apps/stage-pocket/ios/App/CapApp-SPM/Package.swift b/apps/stage-pocket/ios/App/CapApp-SPM/Package.swift index 9b6d2fc48a..4b691b39cb 100644 --- a/apps/stage-pocket/ios/App/CapApp-SPM/Package.swift +++ b/apps/stage-pocket/ios/App/CapApp-SPM/Package.swift @@ -11,10 +11,11 @@ let package = Package( targets: ["CapApp-SPM"]) ], dependencies: [ - .package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.2.0"), - .package(name: "CapacitorBarcodeScanner", path: "../../../../../node_modules/.pnpm/@capacitor+barcode-scanner@3.0.2_@capacitor+core@8.2.0/node_modules/@capacitor/barcode-scanner"), - .package(name: "CapacitorLocalNotifications", path: "../../../../../node_modules/.pnpm/@capacitor+local-notifications@8.0.2_@capacitor+core@8.2.0/node_modules/@capacitor/local-notifications"), - .package(name: "CapacitorNativeSettings", path: "../../../../../node_modules/.pnpm/capacitor-native-settings@8.1.0_@capacitor+core@8.2.0/node_modules/capacitor-native-settings") + .package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.3.1"), + .package(name: "CapacitorApp", path: "../../../../../node_modules/.pnpm/@capacitor+app@8.1.0_@capacitor+core@8.3.1/node_modules/@capacitor/app"), + .package(name: "CapacitorBarcodeScanner", path: "../../../../../node_modules/.pnpm/@capacitor+barcode-scanner@3.0.2_@capacitor+core@8.3.1/node_modules/@capacitor/barcode-scanner"), + .package(name: "CapacitorLocalNotifications", path: "../../../../../node_modules/.pnpm/@capacitor+local-notifications@8.0.2_@capacitor+core@8.3.1/node_modules/@capacitor/local-notifications"), + .package(name: "CapacitorNativeSettings", path: "../../../../../node_modules/.pnpm/capacitor-native-settings@8.1.0_@capacitor+core@8.3.1/node_modules/capacitor-native-settings") ], targets: [ .target( @@ -22,6 +23,7 @@ let package = Package( dependencies: [ .product(name: "Capacitor", package: "capacitor-swift-pm"), .product(name: "Cordova", package: "capacitor-swift-pm"), + .product(name: "CapacitorApp", package: "CapacitorApp"), .product(name: "CapacitorBarcodeScanner", package: "CapacitorBarcodeScanner"), .product(name: "CapacitorLocalNotifications", package: "CapacitorLocalNotifications"), .product(name: "CapacitorNativeSettings", package: "CapacitorNativeSettings") diff --git a/apps/stage-pocket/src/libs/native-auth.test.ts b/apps/stage-pocket/src/libs/native-auth.test.ts new file mode 100644 index 0000000000..900922bee0 --- /dev/null +++ b/apps/stage-pocket/src/libs/native-auth.test.ts @@ -0,0 +1,31 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +let nativeAuthenticate: ReturnType + +vi.mock('@capacitor/core', () => ({ + registerPlugin: () => ({ + authenticate: nativeAuthenticate, + }), +})) + +describe('openNativeAuthSession', () => { + beforeEach(() => { + nativeAuthenticate = vi.fn().mockResolvedValue({ + callbackUrl: 'airi-pocket://auth/callback?code=auth-code&state=state', + }) + vi.resetModules() + }) + + it('opens an ASWebAuthenticationSession with the Pocket callback scheme', async () => { + const { NATIVE_AUTH_CALLBACK_SCHEME, openNativeAuthSession } = await import('./native-auth') + + const callbackUrl = await openNativeAuthSession('https://api.airi.build/api/auth/oauth2/authorize') + + expect(NATIVE_AUTH_CALLBACK_SCHEME).toBe('airi-pocket') + expect(callbackUrl).toBe('airi-pocket://auth/callback?code=auth-code&state=state') + expect(nativeAuthenticate).toHaveBeenCalledWith({ + url: 'https://api.airi.build/api/auth/oauth2/authorize', + callbackScheme: 'airi-pocket', + }) + }) +}) diff --git a/apps/stage-pocket/src/libs/native-auth.ts b/apps/stage-pocket/src/libs/native-auth.ts new file mode 100644 index 0000000000..b28654e6ae --- /dev/null +++ b/apps/stage-pocket/src/libs/native-auth.ts @@ -0,0 +1,22 @@ +import { registerPlugin } from '@capacitor/core' + +export const NATIVE_AUTH_CALLBACK_SCHEME = 'airi-pocket' +export const NATIVE_AUTH_REDIRECT_URI = `${NATIVE_AUTH_CALLBACK_SCHEME}://auth/callback` + +export interface NativeAuthPlugin { + authenticate: (options: { + url: string + callbackScheme: string + }) => Promise<{ callbackUrl: string }> +} + +const nativeAuth: NativeAuthPlugin = registerPlugin('AiriNativeAuth') + +export async function openNativeAuthSession(url: string): Promise { + const result = await nativeAuth.authenticate({ + url, + callbackScheme: NATIVE_AUTH_CALLBACK_SCHEME, + }) + + return result.callbackUrl +} diff --git a/apps/stage-pocket/src/libs/pocket-auth.ts b/apps/stage-pocket/src/libs/pocket-auth.ts new file mode 100644 index 0000000000..846c4e6ffa --- /dev/null +++ b/apps/stage-pocket/src/libs/pocket-auth.ts @@ -0,0 +1,18 @@ +import { applyOIDCTokens, fetchSession } from '@proj-airi/stage-ui/libs/auth' +import { completeOIDCCallbackUrl } from '@proj-airi/stage-ui/libs/auth-callback' +import { consumeFlowState, exchangeCodeForTokens } from '@proj-airi/stage-ui/libs/auth-oidc' +import { configureIOSSignIn } from '@proj-airi/stage-ui/libs/auth-platform' + +import { openNativeAuthSession } from './native-auth' + +export function installPocketAuth(): void { + configureIOSSignIn(async (authorizeUrl) => { + const callbackUrl = await openNativeAuthSession(authorizeUrl) + await completeOIDCCallbackUrl(callbackUrl, { + consumeFlowState, + exchangeCodeForTokens, + applyOIDCTokens, + fetchSession, + }) + }) +} diff --git a/apps/stage-pocket/src/main.ts b/apps/stage-pocket/src/main.ts index ea3e71595e..37e3acb636 100644 --- a/apps/stage-pocket/src/main.ts +++ b/apps/stage-pocket/src/main.ts @@ -15,6 +15,7 @@ import { routes } from 'vue-router/auto-routes' import App from './App.vue' +import { installPocketAuth } from './libs/pocket-auth' import { installDeepLinks } from './modules/deep-links' import { i18n } from './modules/i18n' @@ -51,6 +52,7 @@ window.addEventListener('unhandledrejection', (event) => { }) installDeepLinks(router) +installPocketAuth() createApp(App) .use(MotionPlugin) diff --git a/apps/stage-pocket/src/pages/auth/callback.vue b/apps/stage-pocket/src/pages/auth/callback.vue new file mode 100644 index 0000000000..e2ee1ac9c4 --- /dev/null +++ b/apps/stage-pocket/src/pages/auth/callback.vue @@ -0,0 +1,110 @@ + + + diff --git a/packages/stage-ui/src/libs/auth-callback.test.ts b/packages/stage-ui/src/libs/auth-callback.test.ts new file mode 100644 index 0000000000..e7e56873d7 --- /dev/null +++ b/packages/stage-ui/src/libs/auth-callback.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it, vi } from 'vitest' + +import { completeOIDCCallbackUrl } from './auth-callback' + +describe('completeOIDCCallbackUrl', () => { + it('exchanges the native callback authorization code with the persisted PKCE flow', async () => { + const tokens = { + access_token: 'access-token', + token_type: 'Bearer', + expires_in: 3600, + refresh_token: 'refresh-token', + } + const flowState = { + codeVerifier: 'verifier', + state: 'expected-state', + } + const params = { + clientId: 'airi-stage-pocket', + redirectUri: 'airi-pocket://auth/callback', + provider: 'google' as const, + } + const exchangeCodeForTokens = vi.fn().mockResolvedValue(tokens) + const applyOIDCTokens = vi.fn().mockResolvedValue(undefined) + const fetchSession = vi.fn().mockResolvedValue(true) + + await completeOIDCCallbackUrl('airi-pocket://auth/callback?code=auth-code&state=expected-state', { + consumeFlowState: () => ({ flowState, params }), + exchangeCodeForTokens, + applyOIDCTokens, + fetchSession, + }) + + expect(exchangeCodeForTokens).toHaveBeenCalledWith('auth-code', flowState, params, 'expected-state') + expect(applyOIDCTokens).toHaveBeenCalledWith(tokens, 'airi-stage-pocket') + expect(fetchSession).toHaveBeenCalledOnce() + }) + + it('rejects an OAuth error callback before exchanging tokens', async () => { + const exchangeCodeForTokens = vi.fn() + + await expect(completeOIDCCallbackUrl('airi-pocket://auth/callback?error=access_denied&error_description=User%20cancelled', { + consumeFlowState: () => null, + exchangeCodeForTokens, + applyOIDCTokens: vi.fn(), + fetchSession: vi.fn(), + })).rejects.toThrow('User cancelled') + + expect(exchangeCodeForTokens).not.toHaveBeenCalled() + }) + + it('rejects a callback when the persisted PKCE flow is missing', async () => { + await expect(completeOIDCCallbackUrl('airi-pocket://auth/callback?code=auth-code&state=expected-state', { + consumeFlowState: () => null, + exchangeCodeForTokens: vi.fn(), + applyOIDCTokens: vi.fn(), + fetchSession: vi.fn(), + })).rejects.toThrow('Missing OIDC flow state') + }) +}) diff --git a/packages/stage-ui/src/libs/auth-callback.ts b/packages/stage-ui/src/libs/auth-callback.ts new file mode 100644 index 0000000000..7fcc57d36d --- /dev/null +++ b/packages/stage-ui/src/libs/auth-callback.ts @@ -0,0 +1,42 @@ +import type { OIDCFlowParams, OIDCFlowState, TokenResponse } from './auth-oidc' + +export interface OIDCCallbackCompletionHandlers { + consumeFlowState: () => { flowState: OIDCFlowState, params: OIDCFlowParams } | null + exchangeCodeForTokens: ( + code: string, + flowState: OIDCFlowState, + params: OIDCFlowParams, + returnedState: string, + ) => Promise + applyOIDCTokens: (tokens: TokenResponse, clientId: string) => Promise + fetchSession: () => Promise +} + +export async function completeOIDCCallbackUrl( + callbackUrl: string, + handlers: OIDCCallbackCompletionHandlers, +): Promise { + const url = new URL(callbackUrl) + const code = url.searchParams.get('code') + const state = url.searchParams.get('state') + const errorParam = url.searchParams.get('error') + + if (errorParam) + throw new Error(url.searchParams.get('error_description') ?? errorParam) + + if (!code || !state) + throw new Error('Missing OIDC code or state') + + const persisted = handlers.consumeFlowState() + if (!persisted) + throw new Error('Missing OIDC flow state') + + const tokens = await handlers.exchangeCodeForTokens( + code, + persisted.flowState, + persisted.params, + state, + ) + await handlers.applyOIDCTokens(tokens, persisted.params.clientId) + await handlers.fetchSession() +} diff --git a/packages/stage-ui/src/libs/auth-config.test.ts b/packages/stage-ui/src/libs/auth-config.test.ts new file mode 100644 index 0000000000..a76c542460 --- /dev/null +++ b/packages/stage-ui/src/libs/auth-config.test.ts @@ -0,0 +1,57 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +function stubWindow(capacitor?: { getPlatform: () => string, isNativePlatform?: () => boolean }) { + vi.stubGlobal('window', { + location: { + origin: 'https://airi.moeru.ai', + }, + Capacitor: capacitor, + }) +} + +describe('oIDC client config', () => { + beforeEach(() => { + vi.unstubAllEnvs() + vi.resetModules() + stubWindow() + }) + + it('uses the web redirect URI in browsers', async () => { + const { OIDC_CLIENT_ID, OIDC_REDIRECT_URI } = await import('./auth-config') + + expect(OIDC_CLIENT_ID).toBe('airi-stage-web') + expect(OIDC_REDIRECT_URI).toBe('https://airi.moeru.ai/auth/callback') + }) + + it('uses VITE_OIDC_REDIRECT_URI as the full redirect URI when set', async () => { + vi.stubEnv('VITE_OIDC_REDIRECT_URI', 'https://example.com/auth/callback') + + const { OIDC_REDIRECT_URI } = await import('./auth-config') + + expect(OIDC_REDIRECT_URI).toBe('https://example.com/auth/callback') + }) + + it('uses the app-owned Pocket redirect URI on native platforms', async () => { + stubWindow({ + getPlatform: () => 'ios', + isNativePlatform: () => true, + }) + + const { OIDC_CLIENT_ID, OIDC_REDIRECT_URI } = await import('./auth-config') + + expect(OIDC_CLIENT_ID).toBe('airi-stage-pocket') + expect(OIDC_REDIRECT_URI).toBe('airi-pocket://auth/callback') + }) + + it('keeps the existing Capacitor callback for Android until an Android launcher exists', async () => { + stubWindow({ + getPlatform: () => 'android', + isNativePlatform: () => true, + }) + + const { OIDC_CLIENT_ID, OIDC_REDIRECT_URI } = await import('./auth-config') + + expect(OIDC_CLIENT_ID).toBe('airi-stage-pocket') + expect(OIDC_REDIRECT_URI).toBe('capacitor://localhost/auth/callback') + }) +}) diff --git a/packages/stage-ui/src/libs/auth-config.ts b/packages/stage-ui/src/libs/auth-config.ts index b00401fdae..5fb07d829d 100644 --- a/packages/stage-ui/src/libs/auth-config.ts +++ b/packages/stage-ui/src/libs/auth-config.ts @@ -1,5 +1,10 @@ +import { getCapacitorPlatform } from './capacitor-runtime' + const FALLBACK = 'http://localhost' +const CAPACITOR_WEBVIEW_REDIRECT_URI = 'capacitor://localhost/auth/callback' +const POCKET_IOS_AUTH_REDIRECT_URI = 'airi-pocket://auth/callback' + /** * Safely retrieves environment status without crashing in non-browser runtimes. */ @@ -46,8 +51,17 @@ function getRedirectOrigin() { const { isNative } = getEnvStatus() const origin = getRedirectOrigin() +const capacitorPlatform = getCapacitorPlatform() + +const usesNativeOidcClient = capacitorPlatform !== 'web' || isNative export const OIDC_CLIENT_ID = import.meta.env.VITE_OIDC_CLIENT_ID - || (isNative ? 'airi-stage-pocket' : 'airi-stage-web') + || (usesNativeOidcClient ? 'airi-stage-pocket' : 'airi-stage-web') -export const OIDC_REDIRECT_URI = `${origin}/auth/callback` +export const OIDC_REDIRECT_URI = import.meta.env.VITE_OIDC_REDIRECT_URI + ? import.meta.env.VITE_OIDC_REDIRECT_URI + : capacitorPlatform === 'ios' + ? POCKET_IOS_AUTH_REDIRECT_URI + : capacitorPlatform !== 'web' + ? CAPACITOR_WEBVIEW_REDIRECT_URI + : `${origin}/auth/callback` diff --git a/packages/stage-ui/src/libs/auth-oidc.ts b/packages/stage-ui/src/libs/auth-oidc.ts index 666ff33c5a..dc7f0ea070 100644 --- a/packages/stage-ui/src/libs/auth-oidc.ts +++ b/packages/stage-ui/src/libs/auth-oidc.ts @@ -1,5 +1,6 @@ import { generateCodeChallenge, generateCodeVerifier, generateState } from '@proj-airi/stage-shared/auth' +import { isCapacitorNativePlatform } from './capacitor-runtime' import { SERVER_URL } from './server' // OIDC Authorization Code + PKCE client for all platforms. @@ -142,30 +143,39 @@ export async function refreshAccessToken( return await response.json() } -// Session storage keys for PKCE flow state (survives page navigation during OAuth) +// NOTICE: native shells use localStorage — ASWeb return navigation often drops sessionStorage. const FLOW_STATE_KEY = 'auth/v1/oidc-flow-state' const FLOW_PARAMS_KEY = 'auth/v1/oidc-flow-params' +function getOidcFlowStorage(): Storage { + if (isCapacitorNativePlatform()) + return localStorage + + return sessionStorage +} + /** * Persist OIDC flow state before navigating to the authorization server. */ export function persistFlowState(flowState: OIDCFlowState, params: OIDCFlowParams): void { - sessionStorage.setItem(FLOW_STATE_KEY, JSON.stringify(flowState)) - sessionStorage.setItem(FLOW_PARAMS_KEY, JSON.stringify(params)) + const storage = getOidcFlowStorage() + storage.setItem(FLOW_STATE_KEY, JSON.stringify(flowState)) + storage.setItem(FLOW_PARAMS_KEY, JSON.stringify(params)) } /** * Retrieve and clear persisted OIDC flow state after callback. */ export function consumeFlowState(): { flowState: OIDCFlowState, params: OIDCFlowParams } | null { - const flowStateRaw = sessionStorage.getItem(FLOW_STATE_KEY) - const paramsRaw = sessionStorage.getItem(FLOW_PARAMS_KEY) + const storage = getOidcFlowStorage() + const flowStateRaw = storage.getItem(FLOW_STATE_KEY) + const paramsRaw = storage.getItem(FLOW_PARAMS_KEY) if (!flowStateRaw || !paramsRaw) return null - sessionStorage.removeItem(FLOW_STATE_KEY) - sessionStorage.removeItem(FLOW_PARAMS_KEY) + storage.removeItem(FLOW_STATE_KEY) + storage.removeItem(FLOW_PARAMS_KEY) return { flowState: JSON.parse(flowStateRaw), diff --git a/packages/stage-ui/src/libs/auth-platform.ts b/packages/stage-ui/src/libs/auth-platform.ts new file mode 100644 index 0000000000..1503e67346 --- /dev/null +++ b/packages/stage-ui/src/libs/auth-platform.ts @@ -0,0 +1,11 @@ +export type IOSSignInHandler = (authorizeUrl: string) => Promise + +let iosSignInHandler: IOSSignInHandler | null = null + +export function configureIOSSignIn(handler: IOSSignInHandler | null): void { + iosSignInHandler = handler +} + +export function getIOSSignInHandler(): IOSSignInHandler | null { + return iosSignInHandler +} diff --git a/packages/stage-ui/src/libs/auth.ts b/packages/stage-ui/src/libs/auth.ts index bc21837a6b..5ad6b5faf9 100644 --- a/packages/stage-ui/src/libs/auth.ts +++ b/packages/stage-ui/src/libs/auth.ts @@ -5,6 +5,8 @@ import { createAuthClient } from 'better-auth/vue' import { useAuthStore } from '../stores/auth' import { OIDC_CLIENT_ID, OIDC_REDIRECT_URI } from './auth-config' import { buildAuthorizationURL, persistFlowState } from './auth-oidc' +import { getIOSSignInHandler } from './auth-platform' +import { getCapacitorPlatform } from './capacitor-runtime' import { SERVER_URL } from './server' export type OAuthProvider = 'google' | 'github' @@ -183,17 +185,30 @@ export async function signOut() { * Builds the authorization URL, persists PKCE state, and navigates. */ export async function signInOIDC(params: OIDCFlowParams) { - const { provider, ...oidcParams } = params - const { url, flowState } = await buildAuthorizationURL(oidcParams) + const { url, flowState } = await buildAuthorizationURL(params) persistFlowState(flowState, params) - if (!provider) { + const capacitorPlatform = getCapacitorPlatform() + if (capacitorPlatform === 'ios') { + const iosSignInHandler = getIOSSignInHandler() + if (!iosSignInHandler) + throw new Error('iOS sign-in is not configured') + + await iosSignInHandler(url.toString()) + return + } + if (capacitorPlatform !== 'web') { + window.location.assign(url.toString()) + return + } + + if (!params.provider) { window.location.href = url return } await authClient.signIn.social({ - provider, + provider: params.provider, callbackURL: url.toString(), }) } diff --git a/packages/stage-ui/src/libs/capacitor-runtime.ts b/packages/stage-ui/src/libs/capacitor-runtime.ts new file mode 100644 index 0000000000..3601810410 --- /dev/null +++ b/packages/stage-ui/src/libs/capacitor-runtime.ts @@ -0,0 +1,23 @@ +export function getCapacitorPlatform(): string { + if (typeof window === 'undefined') + return 'web' + + // @ts-expect-error Capacitor is injected by the native runtime when available. + return window.Capacitor?.getPlatform?.() ?? 'web' +} + +export function isCapacitorNativePlatform(): boolean { + if (typeof window === 'undefined') + return false + + // @ts-expect-error Capacitor is injected by the native runtime when available. + const capacitor = window.Capacitor + if (!capacitor) + return false + + if (typeof capacitor.isNativePlatform === 'function') + return capacitor.isNativePlatform() + + const platform = capacitor.getPlatform?.() + return !!platform && platform !== 'web' +} diff --git a/packages/stage-ui/src/stores/auth.ts b/packages/stage-ui/src/stores/auth.ts index e0a05c3439..2706f6385f 100644 --- a/packages/stage-ui/src/stores/auth.ts +++ b/packages/stage-ui/src/stores/auth.ts @@ -7,7 +7,6 @@ import { computed, ref, watch } from 'vue' import { client } from '../composables/api' import { useBreakpoints } from '../composables/use-breakpoints' -import { triggerSignIn } from '../libs/auth' import { refreshAccessToken } from '../libs/auth-oidc' /** @@ -50,7 +49,16 @@ export const useAuthStore = defineStore('auth', () => { whenever(needsLogin, async () => { if (isStageTamagotchi()) return - await triggerSignIn() + try { + const { triggerSignIn } = await import('../libs/auth') + await triggerSignIn() + } + catch { + // Native auth sheet cancelled or sign-in failed. + } + finally { + needsLogin.value = false + } }) // Reset the flag if the viewport class flips, so a stale needsLogin from a