fix(stage-pocket): use system browser for authentication (#2355)
This commit is contained in:
@@ -28,6 +28,7 @@ class MainActivity : BridgeActivity() {
|
||||
|
||||
override fun load() {
|
||||
registerPlugin(MicrophonePermissionPlugin::class.java)
|
||||
registerPlugin(WebAuthenticationPlugin::class.java)
|
||||
super.load()
|
||||
|
||||
val bridge = bridge ?: return
|
||||
|
||||
+35
@@ -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;
|
||||
};
|
||||
|
||||
+3
-3
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
@@ -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({
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user