Skip to content

Commit 3c32b0c

Browse files
committed
feat: implement OIDC login flow
Add SSO/OIDC authentication support using the PKCE authorization code flow. The login screen now detects OIDC support via the /info endpoint (with /version fallback) and shows an SSO login button when available.
1 parent a422894 commit 3c32b0c

9 files changed

Lines changed: 267 additions & 8 deletions

File tree

app/src/main/AndroidManifest.xml

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,18 @@
4343
android:theme="@style/AppTheme.NoActionBar" />
4444
<activity
4545
android:name=".login.LoginActivity"
46-
android:exported="false"
46+
android:exported="true"
4747
android:label="Login"
48+
android:launchMode="singleTop"
4849
android:theme="@style/AppTheme.NoActionBar.Login"
49-
android:windowSoftInputMode="adjustResize" />
50+
android:windowSoftInputMode="adjustResize">
51+
<intent-filter>
52+
<action android:name="android.intent.action.VIEW" />
53+
<category android:name="android.intent.category.DEFAULT" />
54+
<category android:name="android.intent.category.BROWSABLE" />
55+
<data android:scheme="gotify" android:host="oidc" android:path="/callback" />
56+
</intent-filter>
57+
</activity>
5058
<activity
5159
android:name=".log.LogsActivity"
5260
android:exported="false"

app/src/main/kotlin/com/github/gotify/Settings.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,12 @@ internal class Settings(context: Context) {
4545
var clientCertPassword: String?
4646
get() = sharedPreferences.getString("clientCertPass", null)
4747
set(value) = sharedPreferences.edit { putString("clientCertPass", value) }
48+
var oidcCodeVerifier: String?
49+
get() = sharedPreferences.getString("oidc_code_verifier", null)
50+
set(value) = sharedPreferences.edit { putString("oidc_code_verifier", value) }
51+
var oidcState: String?
52+
get() = sharedPreferences.getString("oidc_state", null)
53+
set(value) = sharedPreferences.edit { putString("oidc_state", value) }
4854

4955
init {
5056
sharedPreferences = context.getSharedPreferences("gotify", Context.MODE_PRIVATE)
@@ -61,6 +67,8 @@ internal class Settings(context: Context) {
6167
caCertPath = null
6268
clientCertPath = null
6369
clientCertPassword = null
70+
oidcCodeVerifier = null
71+
oidcState = null
6472
}
6573

6674
fun setUser(name: String?, admin: Boolean) {

app/src/main/kotlin/com/github/gotify/api/ClientFactory.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import com.github.gotify.SSLSettings
44
import com.github.gotify.Settings
55
import com.github.gotify.client.ApiClient
66
import com.github.gotify.client.api.InfoApi
7+
import com.github.gotify.client.api.OidcApi
78
import com.github.gotify.client.api.UserApi
89
import com.github.gotify.client.auth.ApiKeyAuth
910
import com.github.gotify.client.auth.HttpBasicAuth
@@ -45,6 +46,14 @@ internal object ClientFactory {
4546
return unauthorized(settings, sslSettings, baseUrl).createService(InfoApi::class.java)
4647
}
4748

49+
fun oidcApi(
50+
settings: Settings,
51+
sslSettings: SSLSettings = settings.sslSettings(),
52+
baseUrl: String = settings.url
53+
): OidcApi {
54+
return unauthorized(settings, sslSettings, baseUrl).createService(OidcApi::class.java)
55+
}
56+
4857
fun userApiWithToken(settings: Settings): UserApi {
4958
return clientToken(settings).createService(UserApi::class.java)
5059
}

app/src/main/kotlin/com/github/gotify/login/LoginActivity.kt

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import androidx.activity.result.contract.ActivityResultContracts
1212
import androidx.activity.viewModels
1313
import androidx.annotation.StringRes
1414
import androidx.appcompat.app.AppCompatActivity
15+
import androidx.core.net.toUri
1516
import androidx.lifecycle.Lifecycle
1617
import androidx.lifecycle.lifecycleScope
1718
import androidx.lifecycle.repeatOnLifecycle
@@ -107,20 +108,31 @@ internal class LoginActivity : AppCompatActivity() {
107108
}
108109
binding.advancedSettings.setOnClickListener { toggleShowAdvanced() }
109110
binding.login.setOnClickListener { doLogin() }
111+
binding.oidcLogin.setOnClickListener { doOidcLogin() }
110112

111113
viewModel.state.observe(this) { render(it) }
112114
lifecycleScope.launch {
113115
repeatOnLifecycle(Lifecycle.State.STARTED) {
114116
viewModel.events.collect { handleEvent(it) }
115117
}
116118
}
119+
120+
handleOidcCallback(intent)
121+
}
122+
123+
override fun onNewIntent(intent: Intent) {
124+
super.onNewIntent(intent)
125+
setIntent(intent)
126+
handleOidcCallback(intent)
117127
}
118128

119129
private fun render(state: LoginState) {
120130
binding.checkurlProgress.visibility = View.GONE
121131
binding.loginProgress.visibility = View.GONE
132+
binding.oidcLoginProgress.visibility = View.GONE
122133
binding.checkurl.visibility = View.VISIBLE
123134
binding.credentialGroup.visibility = View.GONE
135+
binding.oidcGroup.visibility = View.GONE
124136

125137
when (state) {
126138
LoginState.UrlInput -> {
@@ -143,17 +155,35 @@ internal class LoginActivity : AppCompatActivity() {
143155
binding.login.visibility = View.GONE
144156
binding.loginProgress.visibility = View.VISIBLE
145157
}
158+
LoginState.OidcAuthorizing -> {
159+
showReadyState()
160+
binding.oidcLogin.visibility = View.GONE
161+
binding.oidcLoginProgress.visibility = View.VISIBLE
162+
}
163+
LoginState.OidcWaitingForCallback -> {
164+
showReadyState()
165+
}
166+
LoginState.OidcExchangingToken -> {
167+
binding.checkurl.visibility = View.GONE
168+
binding.checkurlProgress.visibility = View.VISIBLE
169+
}
146170
}
147171
}
148172

149173
private fun showReadyState() {
150-
val version = viewModel.gotifyVersion ?: return
151-
binding.checkurl.text = getString(R.string.found_gotify_version, version)
174+
val info = viewModel.gotifyInfo ?: return
175+
binding.checkurl.text = getString(R.string.found_gotify_version, info.version)
152176
binding.credentialGroup.visibility = View.VISIBLE
177+
if (info.oidc) {
178+
binding.oidcGroup.visibility = View.VISIBLE
179+
}
153180
}
154181

155182
private fun handleEvent(event: LoginEvent) {
156183
when (event) {
184+
is LoginEvent.OpenBrowser -> {
185+
startActivity(Intent(Intent.ACTION_VIEW, event.url.toUri()))
186+
}
157187
LoginEvent.LoginSuccess -> {
158188
Utils.showSnackBar(this, getString(R.string.created_client))
159189
startActivity(Intent(this, InitializationActivity::class.java))
@@ -184,6 +214,12 @@ internal class LoginActivity : AppCompatActivity() {
184214
LoginEvent.ClientCreationFailed -> {
185215
Utils.showSnackBar(this, getString(R.string.create_client_failed))
186216
}
217+
LoginEvent.OidcAuthorizeFailed -> {
218+
Utils.showSnackBar(this, getString(R.string.oidc_authorize_failed))
219+
}
220+
LoginEvent.OidcTokenExchangeFailed -> {
221+
Utils.showSnackBar(this, getString(R.string.oidc_token_exchange_failed))
222+
}
187223
}
188224
}
189225

@@ -206,6 +242,10 @@ internal class LoginActivity : AppCompatActivity() {
206242
viewModel.login(username, password)
207243
}
208244

245+
private fun doOidcLogin() {
246+
showClientNameDialog(viewModel::startOidcAuthorize)
247+
}
248+
209249
private fun showClientNameDialog(onConfirm: (String) -> Unit) {
210250
val clientDialogBinding = ClientNameDialogBinding.inflate(layoutInflater)
211251
val clientDialogEditext = clientDialogBinding.clientNameEditext
@@ -225,6 +265,22 @@ internal class LoginActivity : AppCompatActivity() {
225265
.show()
226266
}
227267

268+
private fun handleOidcCallback(intent: Intent?) {
269+
val data = intent?.data ?: return
270+
if (!data.toString().startsWith(LoginViewModel.OIDC_REDIRECT_URI)) return
271+
272+
val code = data.getQueryParameter("code")
273+
val state = data.getQueryParameter("state")
274+
if (code == null || state == null) {
275+
Logger.warn(
276+
"OIDC callback missing parameters (code=${code != null}, state=${state != null})"
277+
)
278+
return
279+
}
280+
281+
viewModel.handleOidcCallback(code, state)
282+
}
283+
228284
private fun toggleShowAdvanced() {
229285
advancedDialog = AdvancedDialog(this, layoutInflater)
230286
.onDisableSSLChanged { _, disable ->

app/src/main/kotlin/com/github/gotify/login/LoginState.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,19 @@ sealed class LoginState {
77
object LoggingIn : LoginState()
88
object WaitingForClientName : LoginState()
99
object CreatingClient : LoginState()
10+
object OidcAuthorizing : LoginState()
11+
object OidcWaitingForCallback : LoginState()
12+
object OidcExchangingToken : LoginState()
1013
}
1114

1215
sealed class LoginEvent {
16+
data class OpenBrowser(val url: String) : LoginEvent()
1317
object LoginSuccess : LoginEvent()
1418
object ShowClientNameDialog : LoginEvent()
1519
data class VersionError(val url: String, val code: Int) : LoginEvent()
1620
data class VersionException(val url: String, val message: String) : LoginEvent()
1721
object InvalidCredentials : LoginEvent()
1822
object ClientCreationFailed : LoginEvent()
23+
object OidcAuthorizeFailed : LoginEvent()
24+
object OidcTokenExchangeFailed : LoginEvent()
1925
}

app/src/main/kotlin/com/github/gotify/login/LoginViewModel.kt

Lines changed: 110 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,12 @@ import com.github.gotify.client.ApiClient
1111
import com.github.gotify.client.api.ClientApi
1212
import com.github.gotify.client.api.UserApi
1313
import com.github.gotify.client.model.ClientParams
14+
import com.github.gotify.client.model.GotifyInfo
15+
import com.github.gotify.client.model.OIDCExternalAuthorizeRequest
16+
import com.github.gotify.client.model.OIDCExternalTokenRequest
1417
import kotlinx.coroutines.channels.Channel
1518
import kotlinx.coroutines.flow.receiveAsFlow
19+
import org.tinylog.kotlin.Logger
1620

1721
internal class LoginViewModel(private val settings: Settings) : ViewModel() {
1822

@@ -22,27 +26,48 @@ internal class LoginViewModel(private val settings: Settings) : ViewModel() {
2226
private val _events = Channel<LoginEvent>(Channel.BUFFERED)
2327
val events = _events.receiveAsFlow()
2428

25-
var gotifyVersion: String? = null
29+
var gotifyInfo: GotifyInfo? = null
2630
private set
2731

2832
private var authenticatedClient: ApiClient? = null
2933

3034
fun invalidateUrl() {
31-
gotifyVersion = null
35+
gotifyInfo = null
3236
_state.value = LoginState.UrlInput
3337
}
3438

3539
fun checkUrl(url: String) {
3640
_state.value = LoginState.CheckingUrl
3741
settings.url = url
3842

43+
try {
44+
ClientFactory.infoApi(settings, settings.sslSettings(), url)
45+
.info
46+
.enqueue(
47+
Callback.call(
48+
onSuccess = Callback.SuccessBody { info ->
49+
gotifyInfo = info
50+
_state.value = LoginState.Ready
51+
},
52+
onError = { _ -> tryVersionEndpoint(url) }
53+
)
54+
)
55+
} catch (e: Exception) {
56+
tryVersionEndpoint(url)
57+
}
58+
}
59+
60+
private fun tryVersionEndpoint(url: String) {
3961
try {
4062
ClientFactory.infoApi(settings, settings.sslSettings(), url)
4163
.version
4264
.enqueue(
4365
Callback.call(
4466
onSuccess = Callback.SuccessBody { version ->
45-
gotifyVersion = version.version
67+
gotifyInfo = GotifyInfo()
68+
.version(version.version)
69+
.oidc(false)
70+
.register(false)
4671
_state.value = LoginState.Ready
4772
},
4873
onError = { exception ->
@@ -108,10 +133,92 @@ internal class LoginViewModel(private val settings: Settings) : ViewModel() {
108133
_state.value = LoginState.Ready
109134
}
110135

136+
fun startOidcAuthorize(clientName: String) {
137+
_state.value = LoginState.OidcAuthorizing
138+
139+
val codeVerifier = PkceUtil.generateCodeVerifier()
140+
val codeChallenge = PkceUtil.generateCodeChallenge(codeVerifier)
141+
settings.oidcCodeVerifier = codeVerifier
142+
143+
val request = OIDCExternalAuthorizeRequest()
144+
.codeChallenge(codeChallenge)
145+
.redirectUri(OIDC_REDIRECT_URI)
146+
.name(clientName)
147+
148+
Logger.info("OIDC: Requesting redirect url from gotify server: $request")
149+
150+
ClientFactory.oidcApi(settings)
151+
.externalAuthorize(request)
152+
.enqueue(
153+
Callback.call(
154+
onSuccess = Callback.SuccessBody { response ->
155+
Logger.info("OIDC: Requested redirect url: $response")
156+
settings.oidcState = response.state
157+
_state.value = LoginState.OidcWaitingForCallback
158+
_events.trySend(
159+
LoginEvent.OpenBrowser(response.authorizeUrl)
160+
)
161+
},
162+
onError = { exception ->
163+
Logger.error("OIDC: authorize failed: ${exception.message}")
164+
_state.value = LoginState.Ready
165+
_events.trySend(LoginEvent.OidcAuthorizeFailed)
166+
}
167+
)
168+
)
169+
}
170+
171+
fun handleOidcCallback(code: String, oidcState: String) {
172+
val expectedState = settings.oidcState
173+
if (expectedState == null || expectedState != oidcState) {
174+
Logger.warn("OIDC: callback state mismatch (expected=$expectedState, got=$oidcState)")
175+
_events.trySend(LoginEvent.OidcTokenExchangeFailed)
176+
return
177+
}
178+
179+
val verifier = settings.oidcCodeVerifier
180+
if (verifier == null) {
181+
Logger.warn("OIDC: callback missing code verifier")
182+
return
183+
}
184+
185+
settings.oidcCodeVerifier = null
186+
settings.oidcState = null
187+
_state.value = LoginState.OidcExchangingToken
188+
189+
val request = OIDCExternalTokenRequest()
190+
.code(code)
191+
.state(oidcState)
192+
.codeVerifier(verifier)
193+
194+
Logger.error("OIDC: requesting client token: $request")
195+
196+
ClientFactory.oidcApi(settings, settings.sslSettings())
197+
.externalToken(request)
198+
.enqueue(
199+
Callback.call(
200+
onSuccess = Callback.SuccessBody { response ->
201+
settings.token = response.token
202+
Logger.info("OIDC: login successful as ${response.user.name}")
203+
_events.trySend(LoginEvent.LoginSuccess)
204+
},
205+
onError = { exception ->
206+
Logger.error("OIDC: token exchange failed: ${exception.message}")
207+
_state.value = LoginState.Ready
208+
_events.trySend(LoginEvent.OidcTokenExchangeFailed)
209+
}
210+
)
211+
)
212+
}
213+
111214
class Factory(private val settings: Settings) : ViewModelProvider.Factory {
112215
@Suppress("UNCHECKED_CAST")
113216
override fun <T : ViewModel> create(modelClass: Class<T>): T {
114217
return LoginViewModel(settings) as T
115218
}
116219
}
220+
221+
companion object {
222+
const val OIDC_REDIRECT_URI = "gotify://oidc/callback"
223+
}
117224
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package com.github.gotify.login
2+
3+
import android.util.Base64
4+
import java.security.MessageDigest
5+
import java.security.SecureRandom
6+
7+
internal object PkceUtil {
8+
fun generateCodeVerifier(): String {
9+
val bytes = ByteArray(32)
10+
SecureRandom().nextBytes(bytes)
11+
return Base64.encodeToString(bytes, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING)
12+
}
13+
14+
fun generateCodeChallenge(codeVerifier: String): String {
15+
val digest = MessageDigest.getInstance("SHA-256")
16+
.digest(codeVerifier.toByteArray(Charsets.US_ASCII))
17+
return Base64.encodeToString(digest, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING)
18+
}
19+
}

0 commit comments

Comments
 (0)