Skip to content
Draft

OIDC #293

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
1 change: 1 addition & 0 deletions android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ flutter {

dependencies {
def workVersion = "2.9.1"
implementation "androidx.browser:browser:1.9.0"
implementation "androidx.security:security-crypto:1.0.0"
implementation "androidx.work:work-runtime-ktx:$workVersion"
implementation 'com.google.code.gson:gson:2.11.0'
Expand Down
27 changes: 24 additions & 3 deletions android/app/src/main/kotlin/net/defined/mobile_nebula/APIClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,18 @@ class APIClient(context: Context) {
return decodeIncomingSite(res.site)
}

fun tryUpdate(siteName: String, hostID: String, privateKey: String, counter: Long, trustedKeys: String): IncomingSite? {
val res: mobileNebula.TryUpdateResult
fun preauth(): mobileNebula.PreAuthResult {
return client.endpointPreAuth()
}

fun authPoll(pollToken: String): mobileNebula.PollDataResult {
return client.endpointAuthPoll(pollToken)
}

fun longPollWait(siteName: String, hostID: String, privateKey: String, counter: Long, trustedKeys: String): IncomingSite? {
val res: mobileNebula.LongPollWaitResult
try {
res = client.tryUpdate(siteName, hostID, privateKey, counter, trustedKeys)
res = client.longPollWait(siteName, hostID, privateKey, counter, trustedKeys)
} catch (e: Exception) {
// type information from Go is not available, use string matching instead
if (e.message == "invalid credentials") {
Expand All @@ -42,4 +50,17 @@ class APIClient(context: Context) {
private fun decodeIncomingSite(jsonSite: String): IncomingSite {
return gson.fromJson(jsonSite, IncomingSite::class.java)
}

fun reauthenticate(creds: DNCredentials): String {
try {
return client.reauthenticate(creds.hostID, creds.privateKey, creds.counter.toLong(), creds.trustedKeys);
} catch (e: Exception) {
// type information from Go is not available, use string matching instead
if (e.message == "invalid credentials") {
throw InvalidCredentialsException()
}

throw e
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ class DNUpdateWorker(ctx: Context, params: WorkerParameters) : Worker(ctx, param

private fun updateSite(site: Site) {
try {
Log.i(TAG, "updateSite for ${site.name}")
DNUpdateLock(site).use {
val res = updater.updateSite(site)

Expand Down Expand Up @@ -98,7 +99,7 @@ class DNSiteUpdater(

val newSite: IncomingSite?
try {
newSite = apiClient.tryUpdate(
newSite = apiClient.longPollWait(
site.name,
credentials.hostID,
credentials.privateKey,
Expand All @@ -108,11 +109,12 @@ class DNSiteUpdater(
} catch (e: InvalidCredentialsException) {
if (!credentials.invalid) {
site.invalidateDNCredentials(context)
Log.d(TAG, "Invalidated credentials in site ${site.name}")
Log.e(TAG, "Invalidated credentials in site ${site.name}")
return Result.CREDENTIALS_UPDATED
}
return Result.NOOP
}
Log.d(TAG, "Updated site ${site.id}: ${site.name}. Update? ${newSite != null}")

if (newSite != null) {
newSite.save(context)
Expand Down
182 changes: 163 additions & 19 deletions android/app/src/main/kotlin/net/defined/mobile_nebula/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,26 +11,31 @@ import android.content.pm.PackageManager
import android.net.VpnService
import android.os.*
import android.util.Log
import androidx.browser.customtabs.CustomTabsIntent
import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import androidx.work.*
import com.google.gson.Gson
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugins.GeneratedPluginRegistrant
import io.flutter.plugin.common.StandardMethodCodec
import java.io.File
import java.util.concurrent.TimeUnit


const val TAG = "nebula"
const val VPN_START_CODE = 0x10
const val CHANNEL = "net.defined.mobileNebula/NebulaVpnService"
const val BGCHANNEL = "net.defined.mobileNebula/NebulaVpnService/background"
const val UPDATE_WORKER = "dnUpdater"

class MainActivity: FlutterActivity() {
private var ui: MethodChannel? = null
private var bg: MethodChannel? = null

private var inMessenger: Messenger? = Messenger(IncomingHandler())
private var inMessenger: Messenger = Messenger(IncomingHandler())
private var outMessenger: Messenger? = null

private var apiClient: APIClient? = null
Expand All @@ -48,7 +53,7 @@ class MainActivity: FlutterActivity() {
private var activeSiteId: String? = null

private val workManager = WorkManager.getInstance(application)
private val refreshReceiver: BroadcastReceiver = RefreshReceiver()
private var refreshReceiver: BroadcastReceiver? = null

companion object {
const val ACTION_REFRESH_SITES = "net.defined.mobileNebula.REFRESH_SITES"
Expand All @@ -75,6 +80,11 @@ class MainActivity: FlutterActivity() {
"nebula.verifyCertAndKey" -> nebulaVerifyCertAndKey(call, result)

"dn.enroll" -> dnEnroll(call, result)
"dn.getPollToken" -> dnGetPollToken(call, result)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if you want to play with this on iOS, these are the bindings you need to implement.

Below, you can see I also had to make a version of the binding channel that runs on the background executor, to avoid blocking the UI thread. You probably need both.

"dn.usePollToken" -> dnUsePollToken(call, result)
"dn.popBrowser" -> dnPopBrowser(call, result)
"dn.reauthenticate" -> dnReauthenticate(call, result)
"dn.doUpdate" -> dnDoUpdate(result)

"listSites" -> listSites(result)
"deleteSite" -> deleteSite(call, result)
Expand All @@ -96,21 +106,36 @@ class MainActivity: FlutterActivity() {
else -> result.notImplemented()
}
}

val taskQueue = flutterEngine.dartExecutor.binaryMessenger.makeBackgroundTaskQueue()
bg = MethodChannel(flutterEngine.dartExecutor.binaryMessenger,
BGCHANNEL,
StandardMethodCodec.INSTANCE,
taskQueue)

bg!!.setMethodCallHandler { call, result ->
when(call.method) {
"dn.enroll" -> dnEnroll(call, result)
"dn.reauthenticate" -> dnReauthenticate(call, result)
"dn.getPollToken" -> dnGetPollToken(call, result)
"dn.usePollToken" -> dnUsePollToken(call, result)

else -> result.notImplemented()
}
}
}

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

apiClient = APIClient(context)

refreshReceiver = RefreshReceiver()
ContextCompat.registerReceiver(context, refreshReceiver, IntentFilter(ACTION_REFRESH_SITES), ContextCompat.RECEIVER_NOT_EXPORTED)

enqueueDNUpdater()
}

override fun onDestroy() {
super.onDestroy()

unregisterReceiver(refreshReceiver)
}

Expand Down Expand Up @@ -179,26 +204,137 @@ class MainActivity: FlutterActivity() {
}
}

private fun doEnroll(enrollCode: String): Result<Boolean> {
val site: IncomingSite
val siteDir: File
try {
site = apiClient!!.enroll(enrollCode)
siteDir = site.save(context)
} catch (err: Exception) {
return Result.failure(err)
}

val ok = validateOrDeleteSite(siteDir)
Log.w(TAG,"got site: OK? $ok")
if (!ok) {
return Result.failure(Exception("Enrollment failed due to invalid config"))
}
Handler(Looper.getMainLooper()).post {
doRefresh()
}
return Result.success(true)
}

private fun dnEnroll(call: MethodCall, result: MethodChannel.Result) {
val code = call.arguments as String
if (code == "") {
return result.error("required_argument", "code is a required argument", null)
}
val out = doEnroll(code)
return when {
out.isSuccess-> result.success(null)
out.isFailure-> result.error("enroll_failed", out.exceptionOrNull()?.message, null)
else-> result.error("enroll_failed", "unknown", null)
}
}

val site: IncomingSite
val siteDir: File
private fun dnPopBrowser(call: MethodCall, result: MethodChannel.Result) {
val urlToPop = call.arguments as String
if (urlToPop == "") {
return result.error("required_argument", "url is a required argument", null)
}
val customTabsIntent = CustomTabsIntent.Builder()
.setShowTitle(true)
.build()
try {
site = apiClient!!.enroll(code)
siteDir = site.save(context)
customTabsIntent.launchUrl(this, urlToPop.toUri())
} catch (err: Exception) {
return result.error("unhandled_error", err.message, null)
}
return result.success(null)
}

if (!validateOrDeleteSite(siteDir)) {
return result.error("failure", "Enrollment failed due to invalid config", null)
private fun dnGetPollToken(call: MethodCall, result: MethodChannel.Result) {
try {
val resp = apiClient!!.preauth()
val out = mapOf<String, String>("pollToken" to resp.pollToken, "url" to resp.loginURL)
return result.success(out)
} catch (err: Exception) {
return result.error("unhandled_error", err.message, null)
}
}

result.success(null)
private fun dnReauthenticate(call: MethodCall, result: MethodChannel.Result) {
val id = call.argument<String>("id")
if (id == "") {
return result.error("required_argument", "id is a required argument", null)
}

val site = sites!!.getSite(id!!) ?: return result.error("unknown_site", "No site with that id exists", null)
val creds = site.site.getDNCredentials(context)
try {
val resp = apiClient!!.reauthenticate(creds)
return result.success(resp)
} catch (err: Exception) {
return result.error("unhandled_error", err.message, null)
}
}

private fun dnDoUpdate(result: MethodChannel.Result) {
val workRequest = OneTimeWorkRequestBuilder<DNUpdateWorker>().build()
workManager.enqueue(workRequest)
return result.success(null)
}

private fun usePollToken(pt: String): Result<Boolean> {
if (pt == "") {
return Result.failure(Exception("invalid sequence: pollToken is blank"))
}
return try {
val response = apiClient!!.authPoll(pt)
when (response.status) {
"COMPLETED" -> {
if (response.enrollmentCode == "") {
Result.failure(Exception("auth complete, enroll code empty!"))
} else {
doEnroll(response.enrollmentCode)
}
}
"STARTED" -> Result.failure(Exception( "auth incomplete"))
"WAITING" -> Result.failure(Exception( "auth incomplete"))
else -> {
Result.failure(Exception( "auth incomplete, invalid status"))
}
}
} catch (e: Exception) {
Log.e(TAG, "usePollToken threw an exception $e")
return Result.failure(e)
}
}

private fun dnUsePollToken(call: MethodCall, result: MethodChannel.Result) {
val pollToken = call.arguments as String
if (pollToken == "") {
return result.error("required_argument", "pollToken is a required argument", null)
}
val out = usePollToken(pollToken)
return when {
out.isSuccess-> result.success(null)
out.isFailure-> {
val msg = out.exceptionOrNull()?.message
if(msg != null) {
if (msg.contains("resource not found")) {
result.error("oidc_enroll_failed", msg, null)
} else {
result.error("oidc_enroll_incomplete", msg, null)
}
} else {
result.error("oidc_enroll_failed", "unknown", null)
}
}

else-> result.error("oidc_enroll_failed", "unknown", null)
}
}

private fun listSites(result: MethodChannel.Result) {
Expand Down Expand Up @@ -453,6 +589,10 @@ class MainActivity: FlutterActivity() {
bindService(intent, connection, 0)
}

//trigger a doupdate
val workRequest = OneTimeWorkRequestBuilder<DNUpdateWorker>().build()
workManager.enqueue(workRequest)

return result.success(null)
}

Expand Down Expand Up @@ -549,15 +689,19 @@ class MainActivity: FlutterActivity() {
outMessenger = null
}

private fun doRefresh() {
if (sites == null) return

Log.d(TAG, "Refreshing sites in MainActivity")

sites?.refreshSites(activeSiteId)
ui?.invokeMethod("refreshSites", null)
}

inner class RefreshReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent?) {
if (intent?.action != ACTION_REFRESH_SITES) return
if (sites == null) return

Log.d(TAG, "Refreshing sites in MainActivity")

sites?.refreshSites(activeSiteId)
ui?.invokeMethod("refreshSites", null)
doRefresh()
}
}

Expand Down
Loading
Loading