Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
24 changes: 24 additions & 0 deletions .github/workflows/android.yml
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,30 @@ jobs:
find apps/mobile/android/app/build/outputs/apk -name "*.apk" -exec cp -v {} out/ \;
ls -la out

- name: Print signing cert SHA-256 for asset links
# The Android Credential Manager only allows passkeys when the web
# origin publishes /.well-known/assetlinks.json with a fingerprint
# that matches the signing cert of the APK. Print the SHA-256 here
# so it can be pasted into the ANDROID_ASSETLINKS_SHA256 env var on
# the tempest-web service. Debug builds use a per-runner keystore,
# so this changes every run for build_type=debug.
run: |
APK=$(ls out/*.apk | head -n1)
if [ -z "$APK" ]; then
echo "::warning::no APK to inspect"
exit 0
fi
SHA=$(keytool -printcert -jarfile "$APK" 2>/dev/null \
| awk -F': ' '/SHA256:/{print $2; exit}')
if [ -z "$SHA" ]; then
echo "::warning::could not extract SHA-256 from $APK"
exit 0
fi
echo "::notice::Android signing SHA-256 ($BUILD_TYPE): $SHA"
echo "Set ANDROID_ASSETLINKS_SHA256 on tempest-web to this value"
echo "(comma-separated if you need both debug and release):"
echo " $SHA"

- uses: actions/upload-artifact@v4
with:
name: tempest-android-${{ env.BUILD_TYPE }}
Expand Down
11 changes: 11 additions & 0 deletions RAILWAY.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,17 @@ VITE_GATEWAY_URL=wss://tempest-gateway-production-1234.up.railway.app/gateway

After this service deploys, copy its public URL. Go back to `tempest-api` and fix `TEMPEST_RP_ID`, `TEMPEST_RP_ORIGIN`, and `TEMPEST_ALLOWED_ORIGINS` to match the **web** domain (not the api domain).

If you build the Android app, also set these on `tempest-web`:

```
ANDROID_PACKAGE_NAME=chat.tempest.app
ANDROID_ASSETLINKS_SHA256=<SHA-256 of your APK signing cert>
```

The web container renders `/.well-known/assetlinks.json` from these at start so Android Credential Manager will associate the app with this origin and let passkeys work in the Capacitor WebView. Get the SHA-256 from a built APK with `scripts/android-cert-sha.sh path/to/app.apk`, or from the Android workflow log (it prints the value as a `::notice::` after each build). To rotate or accept multiple keys (debug + release), pass them comma-separated.

Without this env var the app still installs but `Sign in with passkey` fails: Credential Manager rejects the request because the RP origin has not authorized the app.

## 6. Wire up the api and gateway URLs the web client uses

The web client reads two build time variables to know where the api and gateway live:
Expand Down
8 changes: 5 additions & 3 deletions apps/mobile/.gitignore
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
# Capacitor scaffolds the native projects on first sync. CI re-runs
# `cap add` so we don't need to commit the generated source trees, and
# committing them would break the workspace pnpm install on machines
# without Android / Xcode toolchains.
android/
ios/
# without Android / Xcode toolchains. The leading slash anchors these
# patterns to apps/mobile/ so plugin source under
# apps/mobile/plugins/<name>/{android,ios}/ is still tracked.
/android/
/ios/
node_modules/
17 changes: 9 additions & 8 deletions apps/mobile/capacitor.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,18 @@ import type { CapacitorConfig } from "@capacitor/cli";
// Capacitor wraps the existing apps/web Vite build into native iOS / Android
// shells. There are two supported modes:
//
// * server.url set (recommended for now): the native app loads the live
// web deployment as its initial page. WebAuthn / passkeys, Service
// Workers, and other origin-locked APIs use the real HTTPS origin and
// work the same as a browser visit. Set TEMPEST_WEB_URL in the build
// env (CI repo var or apps/mobile/.env.production) to enable this.
// * server.url set (recommended): the native app loads the live web
// deployment as its initial page. The bundled tempest-passkey-bridge
// plugin routes passkey ceremonies through Android Credential Manager
// so they work even though Android System WebView does not implement
// navigator.credentials. Set TEMPEST_WEB_URL in the build env (CI
// repo var or apps/mobile/.env.production) to enable this.
//
// * server.url unset (offline-first): the app uses the bundled
// apps/web/dist as the initial page, served from https://localhost.
// Faster first paint, but WebAuthn refuses to run on the localhost
// origin so passkey login does not work. A native passkey bridge is
// needed to use this mode for auth - tracked as future work.
// Faster first paint. The native passkey bridge still works, but the
// RP origin in the WebAuthn challenge has to match a live HTTPS host
// so login is still effectively gated on a real backend.
const liveWebUrl = process.env.TEMPEST_WEB_URL?.trim();

const config: CapacitorConfig = {
Expand Down
3 changes: 2 additions & 1 deletion apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@
"@capacitor/share": "^6.0.2",
"@capacitor/status-bar": "^6.0.2",
"@capacitor/keyboard": "^6.0.2",
"@capacitor/app": "^6.0.1"
"@capacitor/app": "^6.0.1",
"tempest-passkey-bridge": "workspace:*"
},
"devDependencies": {
"@capacitor/cli": "^6.1.2",
Expand Down
17 changes: 17 additions & 0 deletions apps/mobile/plugins/passkey-bridge/TempestPasskeyBridge.podspec
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
require 'json'

package = JSON.parse(File.read(File.join(__dir__, 'package.json')))

Pod::Spec.new do |s|
s.name = 'TempestPasskeyBridge'
s.version = package['version']
s.summary = package['description']
s.license = 'Apache-2.0'
s.homepage = 'https://github.com/HiLleywyn/projecttempest'
s.author = 'HiLleywyn'
s.source = { :git => 'https://github.com/HiLleywyn/projecttempest.git', :tag => s.version.to_s }
s.source_files = 'ios/Plugin/**/*.{swift,h,m,c,cc,mm,cpp}'
s.ios.deployment_target = '14.0'
s.dependency 'Capacitor'
s.swift_version = '5.1'
end
67 changes: 67 additions & 0 deletions apps/mobile/plugins/passkey-bridge/android/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
ext {
junitVersion = project.hasProperty('junitVersion') ? rootProject.ext.junitVersion : '4.13.2'
androidxAppCompatVersion = project.hasProperty('androidxAppCompatVersion') ? rootProject.ext.androidxAppCompatVersion : '1.7.0'
androidxJunitVersion = project.hasProperty('androidxJunitVersion') ? rootProject.ext.androidxJunitVersion : '1.2.1'
androidxEspressoCoreVersion = project.hasProperty('androidxEspressoCoreVersion') ? rootProject.ext.androidxEspressoCoreVersion : '3.6.1'
androidxCredentialsVersion = project.hasProperty('androidxCredentialsVersion') ? rootProject.ext.androidxCredentialsVersion : '1.3.0'
kotlinxCoroutinesVersion = '1.8.1'
}

buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:8.7.2'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.25"
}
}

apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'

android {
namespace "chat.tempest.passkey"
compileSdk project.hasProperty('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 34
defaultConfig {
minSdkVersion project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 22
targetSdkVersion project.hasProperty('targetSdkVersion') ? rootProject.ext.targetSdkVersion : 34
versionCode 1
versionName "0.1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
lintOptions {
abortOnError false
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = '17'
}
}

repositories {
google()
mavenCentral()
}

dependencies {
implementation fileTree(include: ['*.jar'], dir: 'libs')
implementation project(':capacitor-android')
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
implementation "androidx.credentials:credentials:$androidxCredentialsVersion"
implementation "androidx.credentials:credentials-play-services-auth:$androidxCredentialsVersion"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlinxCoroutinesVersion"
testImplementation "junit:junit:$junitVersion"
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
package chat.tempest.passkey

import android.os.Build
import androidx.credentials.CreatePublicKeyCredentialRequest
import androidx.credentials.CreatePublicKeyCredentialResponse
import androidx.credentials.CredentialManager
import androidx.credentials.GetCredentialRequest
import androidx.credentials.GetPublicKeyCredentialOption
import androidx.credentials.PublicKeyCredential
import androidx.credentials.exceptions.CreateCredentialException
import androidx.credentials.exceptions.GetCredentialException
import androidx.credentials.exceptions.NoCredentialException
import com.getcapacitor.JSObject
import com.getcapacitor.Plugin
import com.getcapacitor.PluginCall
import com.getcapacitor.PluginMethod
import com.getcapacitor.annotation.CapacitorPlugin
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch

@CapacitorPlugin(name = "PasskeyBridge")
class PasskeyBridgePlugin : Plugin() {

private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())

override fun handleOnDestroy() {
scope.cancel()
super.handleOnDestroy()
}

@PluginMethod
fun isAvailable(call: PluginCall) {
val ret = JSObject()
if (Build.VERSION.SDK_INT < 28) {
ret.put("available", false)
ret.put("reason", "android_sdk_too_old")
} else {
ret.put("available", true)
}
call.resolve(ret)
}

@PluginMethod
fun create(call: PluginCall) {
val requestJson = call.getString("requestJson")
if (requestJson.isNullOrEmpty()) {
call.reject("requestJson is required")
return
}
val act = activity
if (act == null) {
call.reject("activity is null")
return
}
scope.launch {
try {
val cm = CredentialManager.create(act)
val req = CreatePublicKeyCredentialRequest(requestJson)
val resp = cm.createCredential(act, req)
val pk = resp as? CreatePublicKeyCredentialResponse
if (pk == null) {
call.reject("unexpected_credential_type", resp.javaClass.name)
return@launch
}
val out = JSObject()
out.put("responseJson", pk.registrationResponseJson)
call.resolve(out)
} catch (e: CreateCredentialException) {
call.reject(messageFor(e), e.type, e)
} catch (e: Exception) {
call.reject(e.message ?: "create_credential_failed", e)
}
}
}

@PluginMethod
fun get(call: PluginCall) {
val requestJson = call.getString("requestJson")
if (requestJson.isNullOrEmpty()) {
call.reject("requestJson is required")
return
}
val act = activity
if (act == null) {
call.reject("activity is null")
return
}
scope.launch {
try {
val cm = CredentialManager.create(act)
val option = GetPublicKeyCredentialOption(requestJson)
val req = GetCredentialRequest(listOf(option))
val resp = cm.getCredential(act, req)
val cred = resp.credential
val pk = cred as? PublicKeyCredential
if (pk == null) {
call.reject("unexpected_credential_type", cred.javaClass.name)
return@launch
}
val out = JSObject()
out.put("responseJson", pk.authenticationResponseJson)
call.resolve(out)
} catch (e: NoCredentialException) {
call.reject(messageFor(e), e.type, e)
} catch (e: GetCredentialException) {
call.reject(messageFor(e), e.type, e)
} catch (e: Exception) {
call.reject(e.message ?: "get_credential_failed", e)
}
}
}

private fun messageFor(e: Throwable): String {
val direct = e.message
if (!direct.isNullOrBlank()) return direct
val cause = e.cause?.message
if (!cause.isNullOrBlank()) return cause
return e.javaClass.simpleName
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import Foundation
import Capacitor

// iOS WKWebView has shipped WebAuthn for the standard navigator.credentials
// API since iOS 16, so the JS path works without a native bridge. This file
// exists so cap sync ios doesn't fail; the bridge reports unavailable and
// the web layer falls through to navigator.credentials on iOS.
@objc(PasskeyBridgePlugin)
public class PasskeyBridgePlugin: CAPPlugin, CAPBridgedPlugin {
public let identifier = "PasskeyBridgePlugin"
public let jsName = "PasskeyBridge"
public let pluginMethods: [CAPPluginMethod] = [
CAPPluginMethod(name: "isAvailable", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "create", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "get", returnType: CAPPluginReturnPromise)
]

@objc public func isAvailable(_ call: CAPPluginCall) {
call.resolve([
"available": false,
"reason": "ios_uses_webauthn_in_wkwebview"
])
}

@objc public func create(_ call: CAPPluginCall) {
call.reject("ios_native_bridge_not_implemented")
}

@objc public func get(_ call: CAPPluginCall) {
call.reject("ios_native_bridge_not_implemented")
}
}
29 changes: 29 additions & 0 deletions apps/mobile/plugins/passkey-bridge/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"name": "tempest-passkey-bridge",
"version": "0.1.0",
"private": true,
"description": "Native Android Credential Manager bridge so passkeys work inside the Capacitor WebView. iOS side is a stub that reports unavailable; the web layer falls back to navigator.credentials.",
"main": "src/index.js",
"module": "src/index.js",
"types": "src/index.d.ts",
"type": "module",
"files": [
"android/src/",
"android/build.gradle",
"android/src/main/AndroidManifest.xml",
"ios/Plugin/",
"src/",
"TempestPasskeyBridge.podspec"
],
"capacitor": {
"ios": {
"src": "ios"
},
"android": {
"src": "android"
}
},
"peerDependencies": {
"@capacitor/core": "^6.1.0"
}
}
7 changes: 7 additions & 0 deletions apps/mobile/plugins/passkey-bridge/src/index.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export interface PasskeyBridgePlugin {
isAvailable(): Promise<{ available: boolean; reason?: string }>;
create(options: { requestJson: string }): Promise<{ responseJson: string }>;
get(options: { requestJson: string }): Promise<{ responseJson: string }>;
}

export declare const PasskeyBridge: PasskeyBridgePlugin;
13 changes: 13 additions & 0 deletions apps/mobile/plugins/passkey-bridge/src/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { registerPlugin } from "@capacitor/core";

export const PasskeyBridge = registerPlugin("PasskeyBridge", {
web: async () => ({
isAvailable: async () => ({ available: false, reason: "not_native" }),
create: async () => {
throw new Error("PasskeyBridge.create is native-only");
},
get: async () => {
throw new Error("PasskeyBridge.get is native-only");
},
}),
});
Loading
Loading