Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
7875756
feat(stage-pocket): add OIDC sign-in and callback pages aligned with …
lulu0119 Apr 20, 2026
2a3ebeb
fix(stage-layouts): remove HeaderAvatar dropdown Transition for Capac…
lulu0119 Apr 20, 2026
73ef2e5
feat(stage-ui): [WIP] ngrok header + native OIDC flow
lulu0119 Apr 25, 2026
23cc197
fix(ios): [WIP] ngrok skip interstitial in DEBUG WebView
lulu0119 Apr 25, 2026
2ee5898
fix(pocket): open Google auth in system session
lulu0119 Apr 27, 2026
776cdf5
chore(pocket-ios): remove DEBUG ngrok interstitial workaround in DevB…
lulu0119 Apr 27, 2026
03d083a
fix(server): resolve JWKS locally for token auth
lulu0119 Apr 28, 2026
e379412
docs(dev): document ngrok for local HTTPS API
lulu0119 Apr 30, 2026
b1f3405
chore(stage-ui): sort imports in auth.ts after rebase
lulu0119 Apr 30, 2026
62fdde7
Revert "fix(stage-layouts): remove HeaderAvatar dropdown Transition f…
lulu0119 May 1, 2026
b549322
fix(stage-ui): derive native OIDC client id from Capacitor platform
lulu0119 May 8, 2026
7420c5d
fix(ui-server-auth): alias @capacitor/core for Vite build with stage-ui
lulu0119 May 8, 2026
4048dff
feat(stage-ui): start OIDC from needsLogin via triggerSignIn
lulu0119 May 8, 2026
cfd4013
chore(stage-pocket): remove local auth sign-in page
lulu0119 May 8, 2026
4bad1dd
fix(stage-ui): declare @capacitor/core for shared auth imports
lulu0119 May 19, 2026
d4de0f4
fix(server): resolve JWKS from the bound HOST, not always loopback
lulu0119 May 19, 2026
8f6694e
fix(stage-ui): treat VITE_OIDC_REDIRECT_URI as the full redirect URI
lulu0119 May 19, 2026
404502b
fix(server): reject opaque origins in ADDITIONAL_TRUSTED_ORIGINS
lulu0119 May 19, 2026
4e9b4db
fix(server): correct request-auth test import after libs/tests move
lulu0119 May 19, 2026
5f7609a
[autofix.ci] apply automated fixes
autofix-ci[bot] May 19, 2026
a6a186b
fix(server): bracket IPv6 hosts in local JWKS URL
lulu0119 May 20, 2026
755b58b
fix(stage-pocket): align CapApp-SPM with Capacitor 8.3.1
lulu0119 May 24, 2026
7bb8eca
refactor(stage-ui): remove ngrok skip-browser-warning handling
lulu0119 May 24, 2026
cbbdb0d
fix(server): preserve localhost when resolving local JWKS URL
lulu0119 May 24, 2026
943d249
docs(auth): document OIDC social bridge vs SPA watch split
lulu0119 Jun 9, 2026
f2c61e6
[autofix.ci] apply automated fixes
autofix-ci[bot] Jun 9, 2026
e287a70
refactor(auth): move iOS native auth from stage-ui to stage-pocket
lulu0119 Jun 10, 2026
c27c2b1
Merge branch 'feat/pocket-oidc-server-auth' of https://github.com/lul…
lulu0119 Jun 10, 2026
aa9e825
fix(stage-pocket): wrap DevBridge TLS log for SwiftLint line length
lulu0119 Jun 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
5 changes: 4 additions & 1 deletion apps/server/src/libs/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
],
Expand Down
11 changes: 9 additions & 2 deletions apps/server/src/libs/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
20 changes: 15 additions & 5 deletions apps/server/src/libs/request-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,12 +81,22 @@ function resolveTestAuthToken(env: Env, accessToken: string): RequestAuthSession

let cachedJWKS: ReturnType<typeof createRemoteJWKSet> | 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<typeof createRemoteJWKSet> {
if (!cachedJWKS) {
cachedJWKS = createRemoteJWKSet(
new URL('/api/auth/jwks', env.API_SERVER_URL),
)
}
if (!cachedJWKS)
cachedJWKS = createRemoteJWKSet(localJwksUrl(env))
return cachedJWKS
}

Expand Down
1 change: 1 addition & 0 deletions apps/server/src/libs/tests/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
])
Expand Down
5 changes: 5 additions & 0 deletions apps/server/src/libs/tests/env.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
99 changes: 98 additions & 1 deletion apps/server/src/libs/tests/request-auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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'))
})
})
4 changes: 4 additions & 0 deletions apps/stage-pocket/ios/App/App.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand All @@ -38,6 +39,7 @@
504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
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 = "<group>"; };
/* End PBXFileReference section */

/* Begin PBXFrameworksBuildPhase section */
Expand Down Expand Up @@ -72,6 +74,7 @@
504EC3061FED79650016851F /* App */ = {
isa = PBXGroup;
children = (
A1B2C3D42F10000100AA0008 /* AiriNativeAuthPlugin.swift */,
29ABB4B92F03F2B400285F7F /* DevBridgeViewController.swift */,
A1B2C3D42F10000100AA0002 /* HostWebSocketBridge.swift */,
A1B2C3D42F10000100AA0004 /* URLSessionHostWebSocketSession.swift */,
Expand Down Expand Up @@ -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 */,
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

77 changes: 77 additions & 0 deletions apps/stage-pocket/ios/App/App/AiriNativeAuthPlugin.swift
Original file line number Diff line number Diff line change
@@ -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()
}
}
13 changes: 12 additions & 1 deletion apps/stage-pocket/ios/App/App/DevBridgeViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ class DevBridgeViewController: CAPBridgeViewController {

override func capacitorDidLoad() {
super.capacitorDidLoad()
bridge?.registerPluginInstance(AiriNativeAuthPlugin())
configureTransparentBackground()
webView?.allowsBackForwardNavigationGestures = true
installWebSocketBridge()
Expand Down Expand Up @@ -99,14 +100,17 @@ extension DevBridgeViewController: WKNavigationDelegate {
if let url = navigationAction.request.url {
print("[DevBridge] Navigation request to: \(url.absoluteString)")
}

decisionHandler(.allow)
}

func webView(
_ webView: WKWebView,
didStartProvisionalNavigation navigation: WKNavigation!
) {
print("[DevBridge] Started provisional navigation")
print(
"[DevBridge] didStartProvisional webView.url=\(webView.url?.absoluteString ?? "nil")"
)
}

func webView(
Expand Down Expand Up @@ -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)."
)
}
}
}

Expand Down
Loading
Loading