fix(stage-pocket): use system browser for authentication (#2355)

This commit is contained in:
Neko
2026-08-25 13:54:03 +08:00
committed by GitHub
parent 31fd501694
commit eb5cdbe71a
16 changed files with 288 additions and 74 deletions
@@ -28,6 +28,7 @@ class MainActivity : BridgeActivity() {
override fun load() {
registerPlugin(MicrophonePermissionPlugin::class.java)
registerPlugin(WebAuthenticationPlugin::class.java)
super.load()
val bridge = bridge ?: return
@@ -0,0 +1,35 @@
package ai.moeru.airi_pocket
import android.content.Intent
import android.net.Uri
import com.getcapacitor.Plugin
import com.getcapacitor.PluginCall
import com.getcapacitor.PluginMethod
import com.getcapacitor.annotation.CapacitorPlugin
@CapacitorPlugin(name = "WebAuthentication")
class WebAuthenticationPlugin : Plugin() {
/** Opens the authorization URL in the default system browser. */
@PluginMethod
fun authenticate(call: PluginCall) {
val url = call.getString("url")
if (url.isNullOrBlank()) {
call.reject("The authentication URL is missing.", "INVALID_URL")
return
}
val uri = runCatching { Uri.parse(url) }.getOrNull()
if (uri?.scheme !in setOf("http", "https")) {
call.reject("The authentication URL is invalid.", "INVALID_URL")
return
}
val intent = Intent(Intent.ACTION_VIEW, uri)
try {
activity.startActivity(intent)
call.resolve()
} catch (error: RuntimeException) {
call.reject("No browser can open the authentication URL.", "BROWSER_UNAVAILABLE", error)
}
}
}
@@ -10,6 +10,7 @@
A1B2C3D42F10000100AA0001 /* HostWebSocketBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D42F10000100AA0002 /* HostWebSocketBridge.swift */; };
A1B2C3D42F10000100AA0003 /* URLSessionHostWebSocketSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D42F10000100AA0004 /* URLSessionHostWebSocketSession.swift */; };
A1B2C3D42F10000100AA0005 /* WeakScriptMessageHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D42F10000100AA0006 /* WeakScriptMessageHandler.swift */; };
A1B2C3D42F10000100AA0007 /* WebAuthenticationPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D42F10000100AA0008 /* WebAuthenticationPlugin.swift */; };
0DF9DE312F0E0A42008AB01F /* AppIcon_LiquidGlass.icon in Resources */ = {isa = PBXBuildFile; fileRef = 0DF9DE302F0E0A42008AB01F /* AppIcon_LiquidGlass.icon */; };
29ABB4BA2F03F2B400285F7F /* DevBridgeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29ABB4B92F03F2B400285F7F /* DevBridgeViewController.swift */; };
2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; };
@@ -26,6 +27,7 @@
A1B2C3D42F10000100AA0002 /* HostWebSocketBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostWebSocketBridge.swift; sourceTree = "<group>"; };
A1B2C3D42F10000100AA0004 /* URLSessionHostWebSocketSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSessionHostWebSocketSession.swift; sourceTree = "<group>"; };
A1B2C3D42F10000100AA0006 /* WeakScriptMessageHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeakScriptMessageHandler.swift; sourceTree = "<group>"; };
A1B2C3D42F10000100AA0008 /* WebAuthenticationPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebAuthenticationPlugin.swift; sourceTree = "<group>"; };
0DF9DE302F0E0A42008AB01F /* AppIcon_LiquidGlass.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = AppIcon_LiquidGlass.icon; sourceTree = "<group>"; };
29ABB4B92F03F2B400285F7F /* DevBridgeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevBridgeViewController.swift; sourceTree = "<group>"; };
2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = "<group>"; };
@@ -76,6 +78,7 @@
A1B2C3D42F10000100AA0002 /* HostWebSocketBridge.swift */,
A1B2C3D42F10000100AA0004 /* URLSessionHostWebSocketSession.swift */,
A1B2C3D42F10000100AA0006 /* WeakScriptMessageHandler.swift */,
A1B2C3D42F10000100AA0008 /* WebAuthenticationPlugin.swift */,
50379B222058CBB4000EE86E /* capacitor.config.json */,
504EC3071FED79650016851F /* AppDelegate.swift */,
504EC30B1FED79650016851F /* Main.storyboard */,
@@ -175,6 +178,7 @@
A1B2C3D42F10000100AA0001 /* HostWebSocketBridge.swift in Sources */,
A1B2C3D42F10000100AA0003 /* URLSessionHostWebSocketSession.swift in Sources */,
A1B2C3D42F10000100AA0005 /* WeakScriptMessageHandler.swift in Sources */,
A1B2C3D42F10000100AA0007 /* WebAuthenticationPlugin.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -1,13 +1,13 @@
{
"originHash" : "8765687701ea10de7b4fa5981471aaf26157c825c91c99a7f0e25bbdf71f12e6",
"originHash" : "038c691ceff1e23bf770957b72599f2aea21bf50e55def35674c9756739e4af4",
"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"
}
},
{
@@ -14,6 +14,7 @@ class DevBridgeViewController: CAPBridgeViewController {
override func capacitorDidLoad() {
super.capacitorDidLoad()
bridge?.registerPluginInstance(WebAuthenticationPlugin())
configureTransparentBackground()
webView?.allowsBackForwardNavigationGestures = true
installWebSocketBridge()
+11
View File
@@ -20,6 +20,17 @@
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>ai.moeru.airi-pocket</string>
<key>CFBundleURLSchemes</key>
<array>
<string>ai.moeru.airi-pocket</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSRequiresIPhoneOS</key>
@@ -0,0 +1,99 @@
import AuthenticationServices
import Capacitor
import Foundation
@objc(WebAuthenticationPlugin)
final class WebAuthenticationPlugin: CAPPlugin, CAPBridgedPlugin {
let identifier = "WebAuthenticationPlugin"
let jsName = "WebAuthentication"
let pluginMethods: [CAPPluginMethod] = [
CAPPluginMethod(name: "authenticate", returnType: CAPPluginReturnPromise)
]
private var activeCall: CAPPluginCall?
private var authenticationSession: ASWebAuthenticationSession?
@objc func authenticate(_ call: CAPPluginCall) {
DispatchQueue.main.async { [weak self] in
self?.startAuthentication(call)
}
}
private func startAuthentication(_ call: CAPPluginCall) {
guard activeCall == nil else {
call.reject("An authentication session is already active.", "AUTHENTICATION_IN_PROGRESS")
return
}
guard let urlValue = call.getString("url"),
let url = URL(string: urlValue),
["http", "https"].contains(url.scheme?.lowercased() ?? "") else {
call.reject("The authentication URL is invalid.", "INVALID_URL")
return
}
guard let callbackScheme = call.getString("callbackScheme"), !callbackScheme.isEmpty else {
call.reject("The callback scheme is missing.", "INVALID_CALLBACK_SCHEME")
return
}
activeCall = call
let session = ASWebAuthenticationSession(
url: url,
callbackURLScheme: callbackScheme
) { [weak self] callbackURL, error in
DispatchQueue.main.async {
self?.finishAuthentication(callbackURL: callbackURL, error: error)
}
}
session.presentationContextProvider = self
authenticationSession = session
if !session.start() {
finishAuthentication(
callbackURL: nil,
error: WebAuthenticationError.sessionDidNotStart
)
}
}
private func finishAuthentication(callbackURL: URL?, error: Error?) {
guard let call = activeCall else {
return
}
activeCall = nil
authenticationSession = nil
if let callbackURL {
call.resolve(["callbackUrl": callbackURL.absoluteString])
return
}
if let sessionError = error as? ASWebAuthenticationSessionError,
sessionError.code == .canceledLogin {
call.resolve()
return
}
call.reject(
error?.localizedDescription ?? "The authentication session failed.",
"AUTHENTICATION_FAILED",
error
)
}
}
extension WebAuthenticationPlugin: ASWebAuthenticationPresentationContextProviding {
func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
return bridge?.viewController?.view.window ?? ASPresentationAnchor()
}
}
private enum WebAuthenticationError: LocalizedError {
case sessionDidNotStart
var errorDescription: String? {
return "The authentication session did not start."
}
}
@@ -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")
+19
View File
@@ -4,10 +4,12 @@ import type { Router, RouteRecordRaw } from 'vue-router'
import Tres from '@tresjs/core'
import NProgress from 'nprogress'
import { Capacitor } from '@capacitor/core'
import { autoAnimatePlugin } from '@formkit/auto-animate/vue'
import { isEnvTruthy } from '@proj-airi/stage-shared'
import { trackButtonPlugin } from '@proj-airi/stage-ui/directives/track-button'
import { configureAnalyticsAdapter } from '@proj-airi/stage-ui/libs/analytics'
import { browserAuthorizationHandler, registerAuthorizationHandler } from '@proj-airi/stage-ui/libs/auth'
import { setupSynced } from '@proj-airi/stage-ui/libs/pinia'
import { MotionPlugin } from '@vueuse/motion'
import { createPinia } from 'pinia'
@@ -20,6 +22,7 @@ import App from './App.vue'
import { installDeepLinks } from './modules/deep-links'
import { i18n } from './modules/i18n'
import { WebAuthentication } from './modules/web-authentication'
import '@proj-airi/font-cjkfonts-allseto/index.css'
import '@proj-airi/font-xiaolai/index.css'
@@ -34,6 +37,22 @@ configureAnalyticsAdapter(async (options) => {
return createPosthogAdapter(options)
})
if (Capacitor.isNativePlatform()) {
registerAuthorizationHandler(async ({ authorizationUrl, provider }) => {
const url = new URL(authorizationUrl)
if (provider)
url.searchParams.set('provider', provider)
return await WebAuthentication.authenticate({
callbackScheme: 'ai.moeru.airi-pocket',
url: url.toString(),
})
})
}
else {
registerAuthorizationHandler(browserAuthorizationHandler)
}
const pinia = createPinia()
const synced = setupSynced()
pinia.use(synced.pinia)
+3 -15
View File
@@ -2,8 +2,7 @@ import type { URLOpenListenerEvent } from '@capacitor/app'
import type { Router } from 'vue-router'
import { App } from '@capacitor/app'
import { applyOIDCTokens } from '@proj-airi/stage-ui/libs/auth'
import { consumeFlowState, exchangeCodeForTokens } from '@proj-airi/stage-ui/libs/auth-oidc'
import { completeOIDCSignIn } from '@proj-airi/stage-ui/libs/auth'
export function installDeepLinks(router: Router): void {
App.addListener('appUrlOpen', async (event?: URLOpenListenerEvent) => {
@@ -13,19 +12,8 @@ export function installDeepLinks(router: Router): void {
try {
const url = new URL(event.url)
if (url.host === 'links' && url.pathname === '/auth/callback') {
const code = url.searchParams.get('code')
const state = url.searchParams.get('state')
if (!code || !state) {
return
}
const persisted = consumeFlowState()
if (!persisted) {
console.error('OIDC flow status has expired or is no longer valid.')
return
}
const tokens = await exchangeCodeForTokens(code, persisted.flowState, persisted.params, state)
await applyOIDCTokens(tokens, persisted.params.clientId)
router.replace('/')
if (await completeOIDCSignIn(event.url))
await router.replace('/')
}
}
catch (error) {
@@ -0,0 +1,17 @@
import { registerPlugin } from '@capacitor/core'
interface WebAuthenticationOptions {
callbackScheme: string
url: string
}
interface WebAuthenticationResult {
callbackUrl?: string
}
interface WebAuthenticationPlugin {
authenticate: (options: WebAuthenticationOptions) => Promise<WebAuthenticationResult>
}
/** Opens an authorization URL with the native system browser session. */
export const WebAuthentication = registerPlugin<WebAuthenticationPlugin>('WebAuthentication')
+1 -1
View File
@@ -180,7 +180,7 @@ watch([stream, () => vadLoaded.value], async ([s, loaded]) => {
:background="selectedOption"
:top-color="sampledColor"
>
<div flex="~ col" relative z-2 h-100dvh w-100vw of-hidden py-safe>
<div flex="~ col" relative z-2 h-100dvh w-100vw of-hidden pt-safe>
<!-- header -->
<div class="px-0 py-1 md:px-3 md:py-3" w-full gap-2>
<Header class="hidden md:flex" />
@@ -7,6 +7,7 @@ import { autoAnimatePlugin } from '@formkit/auto-animate/vue'
import { PiniaColada } from '@pinia/colada'
import { trackButtonPlugin } from '@proj-airi/stage-ui/directives/track-button'
import { configureAnalyticsAdapter } from '@proj-airi/stage-ui/libs/analytics'
import { browserAuthorizationHandler, registerAuthorizationHandler } from '@proj-airi/stage-ui/libs/auth'
import { piniaPluginTracing, setupSynced } from '@proj-airi/stage-ui/libs/pinia'
import { MotionPlugin } from '@vueuse/motion'
import { createPinia } from 'pinia'
@@ -44,6 +45,7 @@ configureAnalyticsAdapter(async (options) => {
const { createPosthogAdapter } = await import('@proj-airi/stage-ui/libs/analytics/posthog')
return createPosthogAdapter(options)
})
registerAuthorizationHandler(browserAuthorizationHandler)
const pinia = createPinia()
const synced = setupSynced({
+2
View File
@@ -9,6 +9,7 @@ import { PiniaColada } from '@pinia/colada'
import { isEnvTruthy } from '@proj-airi/stage-shared'
import { trackButtonPlugin } from '@proj-airi/stage-ui/directives/track-button'
import { configureAnalyticsAdapter } from '@proj-airi/stage-ui/libs/analytics'
import { browserAuthorizationHandler, registerAuthorizationHandler } from '@proj-airi/stage-ui/libs/auth'
import { piniaPluginTracing, setupSynced } from '@proj-airi/stage-ui/libs/pinia'
import { MotionPlugin } from '@vueuse/motion'
import { createPinia } from 'pinia'
@@ -33,6 +34,7 @@ configureAnalyticsAdapter(async (options) => {
const { createPosthogAdapter } = await import('@proj-airi/stage-ui/libs/analytics/posthog')
return createPosthogAdapter(options)
})
registerAuthorizationHandler(browserAuthorizationHandler)
const pinia = createPinia()
const synced = setupSynced()
+12 -38
View File
@@ -1,53 +1,27 @@
import { isStageCapacitor } from '@proj-airi/stage-shared'
const FALLBACK = 'http://localhost'
const POCKET_CALLBACK_ORIGIN = 'ai.moeru.airi-pocket://links'
/**
* Safely retrieves environment status without crashing in non-browser runtimes.
*/
function getEnvStatus() {
// If not in a browser environment, return default values immediately
if (typeof window === 'undefined') {
return { isAndroidNative: false, isNative: false }
}
// @ts-expect-error Capacitor is injected by the native runtime when available.
const capacitor = window.Capacitor
const isAndroidNative = !!(capacitor?.getPlatform?.() === 'android')
const isNative = !!capacitor || isAndroidNative
return { isAndroidNative, isNative }
}
/**
* Resolves the redirect origin based on environment and configuration.
*/
function getRedirectOrigin() {
// 1. Priority: Use environment variable if it exists
if (import.meta.env.VITE_OIDC_REDIRECT_URI) {
function getRedirectOrigin(): string {
if (import.meta.env.VITE_OIDC_REDIRECT_URI)
return import.meta.env.VITE_OIDC_REDIRECT_URI
}
const { isAndroidNative } = getEnvStatus()
// Stage Pocket receives the system browser callback through its app URL scheme.
if (isStageCapacitor())
return POCKET_CALLBACK_ORIGIN
// 2. Handle Android Native (Capacitor) environment
if (isAndroidNative) {
return 'ai.moeru.airi-pocket://links'
}
// 3. Handle standard browser environment
if (typeof window !== 'undefined') {
// Browser builds return to the origin that started the authorization flow.
if (typeof window !== 'undefined')
return window.location?.origin ?? FALLBACK
}
// 4. Fallback for SSR/Node.js runtimes
// Non-browser imports need a stable origin while no deployment override exists.
return FALLBACK
}
// --- Export Constants ---
const { isNative } = getEnvStatus()
const origin = getRedirectOrigin()
export const OIDC_CLIENT_ID = import.meta.env.VITE_OIDC_CLIENT_ID
|| (isNative ? 'airi-stage-pocket' : 'airi-stage-web')
|| (isStageCapacitor() ? 'airi-stage-pocket' : 'airi-stage-web')
export const OIDC_REDIRECT_URI = `${origin}/auth/callback`
+72 -13
View File
@@ -3,10 +3,34 @@ import type { OIDCFlowParams, TokenResponse } from './auth-oidc'
import { useAuthStore } from '../stores/auth'
import { authClient } from './auth-client'
import { OIDC_CLIENT_ID, OIDC_REDIRECT_URI } from './auth-config'
import { buildAuthorizationURL, persistFlowState } from './auth-oidc'
import { buildAuthorizationURL, consumeFlowState, exchangeCodeForTokens, persistFlowState } from './auth-oidc'
export type OAuthProvider = 'google' | 'github' | 'steam'
/** An authorization request prepared by the shared OIDC flow. */
export interface AuthorizationRequest {
/** URL of the hosted authorization endpoint. */
authorizationUrl: string
/** Social provider that should start immediately. @default undefined */
provider?: OAuthProvider
}
/** Result returned by a platform authorization handler. */
export interface AuthorizationResult {
/** Callback URL returned directly by the platform, when available. @default undefined */
callbackUrl?: string
}
/** Starts authorization through the active app runtime. */
export type AuthorizationHandler = (request: AuthorizationRequest) => Promise<AuthorizationResult | void>
let authorizationHandler: AuthorizationHandler | undefined
/** Registers the authorization handler owned by the active app runtime. */
export function registerAuthorizationHandler(handler: AuthorizationHandler): void {
authorizationHandler = handler
}
/** Returns the access token from the active auth store. */
export function getAuthToken(): string | null {
return useAuthStore().token
@@ -43,32 +67,67 @@ export async function signOut() {
await useAuthStore().signOut()
}
/**
* Initiate OIDC Authorization Code + PKCE sign-in flow.
* 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)
persistFlowState(flowState, params)
/** Starts authorization with the browser flow used by web renderers. */
export const browserAuthorizationHandler: AuthorizationHandler = async ({ authorizationUrl, provider }) => {
if (!provider) {
window.location.href = url
window.location.href = authorizationUrl
return
}
if (provider === 'steam') {
// Steam is OpenID 2.0; only the Steam plugin endpoint can start it.
await authClient.signIn.steam({ callbackURL: url.toString() })
await authClient.signIn.steam({ callbackURL: authorizationUrl })
return
}
await authClient.signIn.social({
provider,
callbackURL: url.toString(),
callbackURL: authorizationUrl,
})
}
/**
* Completes an OIDC sign-in from a platform callback URL.
*
* The function returns false when the URL is not an OIDC callback.
*/
export async function completeOIDCSignIn(callbackUrl: string): Promise<boolean> {
const url = new URL(callbackUrl)
const code = url.searchParams.get('code')
const state = url.searchParams.get('state')
if (!code || !state)
return false
const persisted = consumeFlowState()
if (!persisted)
throw new Error('OIDC flow status has expired or is no longer valid.')
const tokens = await exchangeCodeForTokens(code, persisted.flowState, persisted.params, state)
await applyOIDCTokens(tokens, persisted.params.clientId)
return true
}
/**
* Initiate OIDC Authorization Code + PKCE sign-in flow.
* Builds the authorization URL, persists PKCE state, and navigates.
*/
export async function signInOIDC(params: OIDCFlowParams) {
const handler = authorizationHandler
if (!handler)
throw new Error('No authorization handler is registered for this app runtime.')
const { provider, ...oidcParams } = params
const { url, flowState } = await buildAuthorizationURL(oidcParams)
persistFlowState(flowState, params)
const result = await handler({
authorizationUrl: url.toString(),
provider,
})
if (result?.callbackUrl)
await completeOIDCSignIn(result.callbackUrl)
}
/**
* Trigger the project-default OIDC sign-in flow.
*