From b448677a7f2d4e6842fd548357fa8441e26c241c Mon Sep 17 00:00:00 2001 From: Anthony Spriggs Date: Fri, 27 Mar 2026 09:29:52 -0400 Subject: [PATCH 1/4] Address pre-release security audit findings (all Critical + High) Fixes all 4 Critical and 5 High findings, plus 5 of 6 Medium and all 3 Low findings from the pre-release security audit. Critical: - [#1] State validation is now unconditional; missing state throws StateMismatch, closing the CSRF / authorization code injection vector - [#2] Public IDmeAuth constructor now requires Context and defaults to EncryptedCredentialStore; CredentialStore demoted to internal - [#3] JWKSClient cache fields are @Volatile and all access is serialised through a Mutex, eliminating the race condition - [#4] policies() sends credentials via HTTP Basic Auth header instead of GET query parameter, keeping the client secret out of server logs High: - [#5] Demo network_security_config.xml removes user-cert trust and sets cleartextTrafficPermitted=false - [#6] iss and aud JWT claims are now mandatory; tokens that omit either throw JWTClaimInvalid instead of silently passing - [#7] JWTValidator validates nbf with 30-second clock skew tolerance and applies the same skew window to exp - [#8] IDmeAuthManager replaces the single CompletableDeferred with a ConcurrentHashMap keyed by state; IDmeAuth passes state as sessionId so callbacks cannot be routed to the wrong flow - [#9] extractJSON is now suspend and calls JWTValidator before decoding, ensuring userinfo JWT signatures are verified before claims are exposed Medium: - [#10] Log.isEnabled flag (default false) gates all SDK log output to prevent credential leakage in release builds - [#11] Redirect URI validation rejects http/https/javascript/file/data schemes in IDmeAuth, AuthorizationRequest, and GroupsRequest - [#12] clearSync() cancels the refresh deferred before nulling state, reducing the window for concurrent-write races - [#13] expiresIn is coerced to [0, 86400] seconds before multiplication, preventing integer-overflow-induced negative expiry timestamps - [#14] AuthViewModel extends AndroidViewModel (provides Context to IDmeAuth); clientSecret is only forwarded in OAUTH mode Low: - [#15] secure-pipeline-ast.yml pinned to immutable commit SHA instead of mutable @master ref - [#17] Demo release build enables minification - [#18] Base64URL.decode() throws IDmeAuthError.InvalidJWT on failure instead of returning null; JWTDecoder call sites cleaned up accordingly Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/secure-pipeline-ast.yml | 2 +- demo/build.gradle.kts | 2 +- .../com/idme/auth/demo/AuthViewModel.kt | 13 +++-- .../main/res/xml/network_security_config.xml | 3 +- sdk/src/main/kotlin/com/idme/auth/IDmeAuth.kt | 53 ++++++++++++------- .../idme/auth/auth/AuthorizationRequest.kt | 7 ++- .../com/idme/auth/auth/GroupsRequest.kt | 7 ++- .../com/idme/auth/auth/IDmeAuthManager.kt | 37 ++++++++----- .../kotlin/com/idme/auth/jwt/JWKSClient.kt | 13 +++-- .../kotlin/com/idme/auth/jwt/JWTDecoder.kt | 3 -- .../kotlin/com/idme/auth/jwt/JWTValidator.kt | 32 +++++++---- .../com/idme/auth/models/TokenResponse.kt | 2 +- .../com/idme/auth/storage/CredentialStore.kt | 13 ++--- .../com/idme/auth/token/TokenManager.kt | 7 ++- .../com/idme/auth/utilities/Base64URL.kt | 9 ++-- .../kotlin/com/idme/auth/utilities/Log.kt | 11 ++-- 16 files changed, 135 insertions(+), 79 deletions(-) diff --git a/.github/workflows/secure-pipeline-ast.yml b/.github/workflows/secure-pipeline-ast.yml index ab158f0..a698535 100644 --- a/.github/workflows/secure-pipeline-ast.yml +++ b/.github/workflows/secure-pipeline-ast.yml @@ -14,5 +14,5 @@ on: jobs: execute: - uses: IDme/workflow-library/.github/workflows/secure-pipeline-ast.yml@master + uses: IDme/workflow-library/.github/workflows/secure-pipeline-ast.yml@7a259bb101fd4f20d7cd0137c1f99e8d60af0859 secrets: inherit diff --git a/demo/build.gradle.kts b/demo/build.gradle.kts index d94dd95..38c071c 100644 --- a/demo/build.gradle.kts +++ b/demo/build.gradle.kts @@ -19,7 +19,7 @@ configure { buildTypes { release { - isMinifyEnabled = false + isMinifyEnabled = true proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" diff --git a/demo/src/main/kotlin/com/idme/auth/demo/AuthViewModel.kt b/demo/src/main/kotlin/com/idme/auth/demo/AuthViewModel.kt index c747df6..ee6c8c7 100644 --- a/demo/src/main/kotlin/com/idme/auth/demo/AuthViewModel.kt +++ b/demo/src/main/kotlin/com/idme/auth/demo/AuthViewModel.kt @@ -1,10 +1,11 @@ package com.idme.auth.demo import android.app.Activity +import android.app.Application import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.lifecycle.ViewModel +import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.idme.auth.IDmeAuth import com.idme.auth.configuration.IDmeAuthMode @@ -17,7 +18,7 @@ import com.idme.auth.models.Credentials import com.idme.auth.models.Policy import kotlinx.coroutines.launch -class AuthViewModel : ViewModel() { +class AuthViewModel(application: Application) : AndroidViewModel(application) { // MARK: - Configuration Inputs @@ -196,6 +197,10 @@ class AuthViewModel : ViewModel() { finalScopes.add(0, IDmeScope.OPENID) } + // Client secret is only applicable for server-side OAuth flows; never embed in PKCE or OIDC. + // TODO: Load credentials from local.properties via BuildConfig rather than hardcoding. + val secret = if (authMode == IDmeAuthMode.OAUTH) clientSecret else null + val config = IDmeConfiguration( clientId = clientId, redirectURI = redirectURI, @@ -203,9 +208,9 @@ class AuthViewModel : ViewModel() { environment = environment, authMode = authMode, verificationType = verificationType, - clientSecret = clientSecret + clientSecret = secret ) - return IDmeAuth(config) + return IDmeAuth(config, getApplication()) } } diff --git a/demo/src/main/res/xml/network_security_config.xml b/demo/src/main/res/xml/network_security_config.xml index d20fb83..683208f 100644 --- a/demo/src/main/res/xml/network_security_config.xml +++ b/demo/src/main/res/xml/network_security_config.xml @@ -1,9 +1,8 @@ - + - diff --git a/sdk/src/main/kotlin/com/idme/auth/IDmeAuth.kt b/sdk/src/main/kotlin/com/idme/auth/IDmeAuth.kt index 2f526a7..c3f0bb5 100644 --- a/sdk/src/main/kotlin/com/idme/auth/IDmeAuth.kt +++ b/sdk/src/main/kotlin/com/idme/auth/IDmeAuth.kt @@ -1,6 +1,7 @@ package com.idme.auth import android.app.Activity +import android.content.Context import android.net.Uri import com.idme.auth.auth.AuthorizationRequest import com.idme.auth.auth.GroupsRequest @@ -13,6 +14,7 @@ import com.idme.auth.configuration.IDmeVerificationType import com.idme.auth.errors.IDmeAuthError import com.idme.auth.jwt.JWKSClient import com.idme.auth.jwt.JWKSFetching +import com.idme.auth.jwt.JWTDecoder import com.idme.auth.jwt.JWTValidator import com.idme.auth.models.AttributeResponse import com.idme.auth.models.Credentials @@ -21,7 +23,7 @@ import com.idme.auth.models.UserInfo import com.idme.auth.networking.APIEndpoint import com.idme.auth.networking.DefaultHTTPClient import com.idme.auth.networking.HTTPClient -import com.idme.auth.storage.CredentialStore +import com.idme.auth.storage.EncryptedCredentialStore import com.idme.auth.token.TokenManager import com.idme.auth.token.TokenRefresher import com.idme.auth.utilities.Log @@ -31,7 +33,6 @@ import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive -import com.idme.auth.jwt.JWTDecoder /** * Main entry point for the IDmeAuthSDK. @@ -45,7 +46,8 @@ import com.idme.auth.jwt.JWTDecoder * redirectURI = "yourapp://idme/callback", * scopes = listOf(IDmeScope.MILITARY), * verificationType = IDmeVerificationType.SINGLE - * ) + * ), + * context = applicationContext * ) * * val credentials = idme.login(activity) @@ -59,11 +61,11 @@ class IDmeAuth( ) { private var lastNonce: String? = null - /** Creates a new IDmeAuth instance with the given configuration. */ - constructor(configuration: IDmeConfiguration) : this( + /** Creates a new IDmeAuth instance with the given configuration. Credentials are stored in EncryptedSharedPreferences. */ + constructor(configuration: IDmeConfiguration, context: Context) : this( configuration = configuration, tokenManager = TokenManager( - credentialStore = CredentialStore(), + credentialStore = EncryptedCredentialStore(context), refresher = TokenRefresher(configuration, DefaultHTTPClient()) ), httpClient = DefaultHTTPClient(), @@ -109,7 +111,7 @@ class IDmeAuth( Log.info("Starting auth session: ${configuration.verificationType.value} mode") - val callbackURL = IDmeAuthManager.launchAuth(activity, authURL) + val callbackURL = IDmeAuthManager.launchAuth(activity, authURL, state) val code = extractAuthorizationCode(callbackURL, state) @@ -147,21 +149,20 @@ class IDmeAuth( /** * Fetches the available verification policies for the organization. * - * Uses the client credentials (client_id and client_secret) to authenticate. + * Uses the client credentials (client_id and client_secret) to authenticate via HTTP Basic Auth. * The policy `handle` can be used as the OAuth `scope` parameter. * * @return A list of available policies. */ suspend fun policies(): List { - val baseUrl = APIEndpoint.policies(configuration.environment) - val url = "$baseUrl?client_id=${ - java.net.URLEncoder.encode(configuration.clientId, "UTF-8") - }&client_secret=${ - java.net.URLEncoder.encode(configuration.clientSecret ?: "", "UTF-8") - }" + val url = APIEndpoint.policies(configuration.environment) + val credentials = "${configuration.clientId}:${configuration.clientSecret ?: ""}" + val encoded = java.util.Base64.getEncoder() + .encodeToString(credentials.toByteArray(Charsets.UTF_8)) + val headers = mapOf("Authorization" to "Basic $encoded") val response = try { - httpClient.get(url, mapOf()) + httpClient.get(url, headers) } catch (e: IDmeAuthError) { throw e } catch (e: Exception) { @@ -303,10 +304,16 @@ class IDmeAuth( // MARK: - Private - /** Extracts JSON data from a response that may be plain JSON or a JWT. */ - private fun extractJSON(body: String): String { + /** + * Extracts JSON data from a response that may be plain JSON or a JWT. + * If the body is a JWT, the signature is verified before claims are extracted. + */ + private suspend fun extractJSON(body: String): String { val trimmed = body.trim().trim('"') if (trimmed.startsWith("eyJ")) { + val issuer = "${configuration.environment.apiBaseURL}oidc" + val validator = JWTValidator(jwksFetcher, issuer, configuration.clientId) + validator.validate(trimmed, null) val decoded = JWTDecoder.decode(trimmed) return Json.encodeToString(JsonObject.serializer(), JsonObject(decoded.payload.mapValues { (_, v) -> JsonPrimitive(v.toString()) @@ -337,7 +344,8 @@ class IDmeAuth( throw IDmeAuthError.GroupsNotAvailableInSandbox } - if (Uri.parse(configuration.redirectURI).scheme == null) { + val scheme = Uri.parse(configuration.redirectURI).scheme + if (scheme == null || scheme.lowercase() in DISALLOWED_REDIRECT_SCHEMES) { throw IDmeAuthError.InvalidRedirectURI } } @@ -354,9 +362,10 @@ class IDmeAuth( throw IDmeAuthError.TokenExchangeFailed(0, description) } - // Validate state + // Validate state — mandatory; a missing state is treated as a CSRF/injection attempt val returnedState = uri.getQueryParameter("state") - if (returnedState != null && returnedState != expectedState) { + ?: throw IDmeAuthError.StateMismatch + if (returnedState != expectedState) { throw IDmeAuthError.StateMismatch } @@ -364,4 +373,8 @@ class IDmeAuth( return uri.getQueryParameter("code") ?: throw IDmeAuthError.MissingAuthorizationCode } + + companion object { + private val DISALLOWED_REDIRECT_SCHEMES = setOf("http", "https", "javascript", "file", "data") + } } diff --git a/sdk/src/main/kotlin/com/idme/auth/auth/AuthorizationRequest.kt b/sdk/src/main/kotlin/com/idme/auth/auth/AuthorizationRequest.kt index 84a2849..ef56e09 100644 --- a/sdk/src/main/kotlin/com/idme/auth/auth/AuthorizationRequest.kt +++ b/sdk/src/main/kotlin/com/idme/auth/auth/AuthorizationRequest.kt @@ -16,7 +16,8 @@ class AuthorizationRequest(configuration: IDmeConfiguration) { val pkce: PKCEGenerator? init { - if (android.net.Uri.parse(configuration.redirectURI).scheme == null) { + val uriScheme = android.net.Uri.parse(configuration.redirectURI).scheme + if (uriScheme == null || uriScheme.lowercase() in DISALLOWED_SCHEMES) { throw IDmeAuthError.InvalidRedirectURI } @@ -57,4 +58,8 @@ class AuthorizationRequest(configuration: IDmeConfiguration) { } url = "$baseUrl?$queryString" } + + companion object { + private val DISALLOWED_SCHEMES = setOf("http", "https", "javascript", "file", "data") + } } diff --git a/sdk/src/main/kotlin/com/idme/auth/auth/GroupsRequest.kt b/sdk/src/main/kotlin/com/idme/auth/auth/GroupsRequest.kt index ca8c679..bc248ee 100644 --- a/sdk/src/main/kotlin/com/idme/auth/auth/GroupsRequest.kt +++ b/sdk/src/main/kotlin/com/idme/auth/auth/GroupsRequest.kt @@ -21,7 +21,8 @@ class GroupsRequest(configuration: IDmeConfiguration) { throw IDmeAuthError.GroupsNotAvailableInSandbox } - if (android.net.Uri.parse(configuration.redirectURI).scheme == null) { + val uriScheme = android.net.Uri.parse(configuration.redirectURI).scheme + if (uriScheme == null || uriScheme.lowercase() in DISALLOWED_SCHEMES) { throw IDmeAuthError.InvalidRedirectURI } @@ -62,4 +63,8 @@ class GroupsRequest(configuration: IDmeConfiguration) { } url = "$baseUrl?$queryString" } + + companion object { + private val DISALLOWED_SCHEMES = setOf("http", "https", "javascript", "file", "data") + } } diff --git a/sdk/src/main/kotlin/com/idme/auth/auth/IDmeAuthManager.kt b/sdk/src/main/kotlin/com/idme/auth/auth/IDmeAuthManager.kt index 25c7afb..c7313e1 100644 --- a/sdk/src/main/kotlin/com/idme/auth/auth/IDmeAuthManager.kt +++ b/sdk/src/main/kotlin/com/idme/auth/auth/IDmeAuthManager.kt @@ -4,6 +4,7 @@ import android.app.Activity import android.net.Uri import androidx.browser.customtabs.CustomTabsIntent import com.idme.auth.errors.IDmeAuthError +import java.util.concurrent.ConcurrentHashMap import kotlinx.coroutines.CompletableDeferred /** @@ -11,13 +12,14 @@ import kotlinx.coroutines.CompletableDeferred * and the coroutine-based SDK API. * * Flow: - * 1. [launchAuth] stores a [CompletableDeferred] and opens a Custom Tab. + * 1. [launchAuth] stores a [CompletableDeferred] keyed by the session's `state` value and opens a Custom Tab. * 2. The browser redirects to the app's scheme, which is caught by [IDmeRedirectActivity]. - * 3. [handleRedirect] completes the deferred with the callback URL. + * 3. [handleRedirect] extracts the `state` from the callback URL and routes the result to the + * matching deferred, preventing cross-flow code injection. * 4. [launchAuth] resumes and returns the callback URL to the caller. */ internal object IDmeAuthManager { - private var pendingAuth: CompletableDeferred? = null + private val pending = ConcurrentHashMap>() /** * Launches the authentication flow in a Chrome Custom Tab and suspends @@ -25,14 +27,12 @@ internal object IDmeAuthManager { * * @param activity The Activity to launch from. * @param authUrl The authorization URL to open. + * @param sessionId The `state` value for this flow; used to route the callback to the correct coroutine. * @return The full callback URL string including query parameters. */ - suspend fun launchAuth(activity: Activity, authUrl: String): String { - // Cancel any existing pending auth - pendingAuth?.cancel() - + suspend fun launchAuth(activity: Activity, authUrl: String, sessionId: String): String { val deferred = CompletableDeferred() - pendingAuth = deferred + pending[sessionId] = deferred val customTabsIntent = CustomTabsIntent.Builder() .setShowTitle(true) @@ -45,17 +45,28 @@ internal object IDmeAuthManager { } catch (e: kotlinx.coroutines.CancellationException) { throw IDmeAuthError.UserCancelled } finally { - pendingAuth = null + pending.remove(sessionId) } } - /** Called by [IDmeRedirectActivity] when the redirect URI is received. */ + /** Called by [IDmeRedirectActivity] when the redirect URI is received. Routes to the matching session. */ internal fun handleRedirect(callbackUrl: String) { - pendingAuth?.complete(callbackUrl) + val state = try { + Uri.parse(callbackUrl).getQueryParameter("state") + } catch (_: Exception) { + null + } + if (state != null) { + pending[state]?.complete(callbackUrl) + } else { + // No state in callback (error or non-compliant response) — deliver to any waiting flow + pending.values.firstOrNull()?.complete(callbackUrl) + } } - /** Called by [IDmeRedirectActivity] when no URI data is present. */ + /** Called by [IDmeRedirectActivity] when no URI data is present. Cancels all pending flows. */ internal fun handleCancel() { - pendingAuth?.completeExceptionally(IDmeAuthError.UserCancelled) + pending.values.forEach { it.completeExceptionally(IDmeAuthError.UserCancelled) } + pending.clear() } } diff --git a/sdk/src/main/kotlin/com/idme/auth/jwt/JWKSClient.kt b/sdk/src/main/kotlin/com/idme/auth/jwt/JWKSClient.kt index db2bf81..99159f5 100644 --- a/sdk/src/main/kotlin/com/idme/auth/jwt/JWKSClient.kt +++ b/sdk/src/main/kotlin/com/idme/auth/jwt/JWKSClient.kt @@ -6,6 +6,8 @@ import com.idme.auth.models.JWKS import com.idme.auth.networking.APIEndpoint import com.idme.auth.networking.DefaultHTTPClient import com.idme.auth.networking.HTTPClient +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.serialization.json.Json /** Interface for fetching JWKS, enabling mock injection. */ @@ -20,16 +22,17 @@ class JWKSClient( private val cacheTTL: Long = 3600_000L // 1 hour in milliseconds ) : JWKSFetching { - private var cached: JWKS? = null - private var cacheTime: Long = 0 + @Volatile private var cached: JWKS? = null + @Volatile private var cacheTime: Long = 0 + private val cacheMutex = Mutex() private val json = Json { ignoreUnknownKeys = true } - override suspend fun fetchJWKS(): JWKS { + override suspend fun fetchJWKS(): JWKS = cacheMutex.withLock { val now = System.currentTimeMillis() val cachedValue = cached if (cachedValue != null && (now - cacheTime) < cacheTTL) { - return cachedValue + return@withLock cachedValue } val url = APIEndpoint.jwks(environment) @@ -55,6 +58,6 @@ class JWKSClient( cached = jwks cacheTime = now - return jwks + jwks } } diff --git a/sdk/src/main/kotlin/com/idme/auth/jwt/JWTDecoder.kt b/sdk/src/main/kotlin/com/idme/auth/jwt/JWTDecoder.kt index b9b61e4..84c09f2 100644 --- a/sdk/src/main/kotlin/com/idme/auth/jwt/JWTDecoder.kt +++ b/sdk/src/main/kotlin/com/idme/auth/jwt/JWTDecoder.kt @@ -52,7 +52,6 @@ object JWTDecoder { // Decode header val headerData = Base64URL.decode(headerPart) - ?: throw IDmeAuthError.InvalidJWT("Failed to decode JWT header") val headerJSON = try { Json.parseToJsonElement(String(headerData, Charsets.UTF_8)) as JsonObject } catch (e: Exception) { @@ -66,7 +65,6 @@ object JWTDecoder { // Decode payload val payloadData = Base64URL.decode(payloadPart) - ?: throw IDmeAuthError.InvalidJWT("Failed to decode JWT payload") val payloadJSON = try { Json.parseToJsonElement(String(payloadData, Charsets.UTF_8)) as JsonObject } catch (e: Exception) { @@ -80,7 +78,6 @@ object JWTDecoder { // Decode signature val signatureData = Base64URL.decode(signaturePart) - ?: throw IDmeAuthError.InvalidJWT("Failed to decode JWT signature") val signedPortion = "$headerPart.$payloadPart" diff --git a/sdk/src/main/kotlin/com/idme/auth/jwt/JWTValidator.kt b/sdk/src/main/kotlin/com/idme/auth/jwt/JWTValidator.kt index 860afb4..8b0275f 100644 --- a/sdk/src/main/kotlin/com/idme/auth/jwt/JWTValidator.kt +++ b/sdk/src/main/kotlin/com/idme/auth/jwt/JWTValidator.kt @@ -45,14 +45,18 @@ class JWTValidator( } private fun validateClaims(payload: Map, nonce: String?) { - // Issuer + val now = System.currentTimeMillis() + + // Issuer — mandatory per OpenID Connect Core 1.0 Section 3.1.3.7 val iss = payload["iss"] as? String - if (iss != null && iss != issuer) { + ?: throw IDmeAuthError.JWTClaimInvalid("iss", "Missing issuer claim") + if (iss != issuer) { throw IDmeAuthError.JWTClaimInvalid("iss", "Expected $issuer, got $iss") } - // Audience + // Audience — mandatory per OpenID Connect Core 1.0 Section 3.1.3.7 val aud = payload["aud"] + ?: throw IDmeAuthError.JWTClaimInvalid("aud", "Missing audience claim") when (aud) { is String -> { if (aud != clientId) { @@ -66,18 +70,24 @@ class JWTValidator( } } - // Expiration + // Expiration — with clock skew tolerance val exp = payload["exp"] if (exp != null) { - val expTime = when (exp) { - is Number -> exp.toLong() * 1000 - else -> null - } - if (expTime != null && System.currentTimeMillis() >= expTime) { + val expTime = (exp as? Number)?.toLong()?.times(1000) + if (expTime != null && now >= expTime + CLOCK_SKEW_MS) { throw IDmeAuthError.JWTClaimInvalid("exp", "Token has expired") } } + // Not Before — reject tokens that are not yet valid + val nbf = payload["nbf"] + if (nbf != null) { + val nbfTime = (nbf as? Number)?.toLong()?.times(1000) + if (nbfTime != null && now < nbfTime - CLOCK_SKEW_MS) { + throw IDmeAuthError.JWTClaimInvalid("nbf", "Token not yet valid") + } + } + // Nonce (OIDC) if (nonce != null) { val tokenNonce = payload["nonce"] as? String @@ -87,4 +97,8 @@ class JWTValidator( } } } + + companion object { + private const val CLOCK_SKEW_MS = 30_000L // 30 seconds + } } diff --git a/sdk/src/main/kotlin/com/idme/auth/models/TokenResponse.kt b/sdk/src/main/kotlin/com/idme/auth/models/TokenResponse.kt index abc2dfd..aae0141 100644 --- a/sdk/src/main/kotlin/com/idme/auth/models/TokenResponse.kt +++ b/sdk/src/main/kotlin/com/idme/auth/models/TokenResponse.kt @@ -19,6 +19,6 @@ data class TokenResponse( refreshToken = refreshToken, idToken = idToken, tokenType = tokenType, - expiresAt = System.currentTimeMillis() + (expiresIn * 1000L) + expiresAt = System.currentTimeMillis() + (expiresIn.toLong().coerceIn(0, 86400) * 1000L) ) } diff --git a/sdk/src/main/kotlin/com/idme/auth/storage/CredentialStore.kt b/sdk/src/main/kotlin/com/idme/auth/storage/CredentialStore.kt index 05237f6..3b0f586 100644 --- a/sdk/src/main/kotlin/com/idme/auth/storage/CredentialStore.kt +++ b/sdk/src/main/kotlin/com/idme/auth/storage/CredentialStore.kt @@ -13,16 +13,13 @@ interface CredentialStoring { } /** - * Persists [Credentials] to Android SharedPreferences. + * In-memory credential store for testing purposes only. * - * For production use, consumers should use [EncryptedCredentialStore] which wraps - * EncryptedSharedPreferences. This basic implementation uses standard SharedPreferences - * and is suitable for development and testing. - * - * Note: The SDK initializes with this store by default. To use EncryptedSharedPreferences, - * pass an [EncryptedCredentialStore] when constructing [IDmeAuth] via the internal constructor. + * This store holds credentials in a plain String field with no encryption. + * It is intentionally internal and must not be used in production. + * The [IDmeAuth] public constructor uses [EncryptedCredentialStore] by default. */ -class CredentialStore : CredentialStoring { +internal class CredentialStore : CredentialStoring { private val json = Json { ignoreUnknownKeys = true } // In-memory storage as a fallback when no Android Context is available. diff --git a/sdk/src/main/kotlin/com/idme/auth/token/TokenManager.kt b/sdk/src/main/kotlin/com/idme/auth/token/TokenManager.kt index fc21742..4d065df 100644 --- a/sdk/src/main/kotlin/com/idme/auth/token/TokenManager.kt +++ b/sdk/src/main/kotlin/com/idme/auth/token/TokenManager.kt @@ -90,11 +90,14 @@ class TokenManager( } } - /** Clears all stored credentials synchronously (for logout from non-suspend context). */ + /** + * Clears all stored credentials synchronously (for logout from non-suspend context). + * Prefer the suspending [clear] to avoid races with concurrent refresh operations. + */ fun clearSync() { - cachedCredentials = null refreshDeferred?.cancel() refreshDeferred = null + cachedCredentials = null credentialStore.delete() } } diff --git a/sdk/src/main/kotlin/com/idme/auth/utilities/Base64URL.kt b/sdk/src/main/kotlin/com/idme/auth/utilities/Base64URL.kt index e85b673..643aa9a 100644 --- a/sdk/src/main/kotlin/com/idme/auth/utilities/Base64URL.kt +++ b/sdk/src/main/kotlin/com/idme/auth/utilities/Base64URL.kt @@ -1,5 +1,6 @@ package com.idme.auth.utilities +import com.idme.auth.errors.IDmeAuthError import java.util.Base64 /** Base64URL encoding/decoding per RFC 4648 section 5. */ @@ -9,11 +10,11 @@ object Base64URL { fun encode(data: ByteArray): String = Base64.getUrlEncoder().withoutPadding().encodeToString(data) - /** Decodes a Base64URL string to raw bytes. */ - fun decode(string: String): ByteArray? = + /** Decodes a Base64URL string to raw bytes. Throws [IDmeAuthError.InvalidJWT] on invalid input. */ + fun decode(string: String): ByteArray = try { Base64.getUrlDecoder().decode(string) - } catch (_: Exception) { - null + } catch (e: Exception) { + throw IDmeAuthError.InvalidJWT("Invalid Base64URL encoding: ${e.message}") } } diff --git a/sdk/src/main/kotlin/com/idme/auth/utilities/Log.kt b/sdk/src/main/kotlin/com/idme/auth/utilities/Log.kt index 346a50b..cf8e1af 100644 --- a/sdk/src/main/kotlin/com/idme/auth/utilities/Log.kt +++ b/sdk/src/main/kotlin/com/idme/auth/utilities/Log.kt @@ -1,18 +1,21 @@ package com.idme.auth.utilities -/** Internal logger wrapper using Android's Log. */ +/** Internal logger wrapper using Android's Log. Disabled by default; enable via [isEnabled]. */ object Log { private const val TAG = "IDmeAuthSDK" + /** Set to true to enable SDK log output. Disabled by default to prevent credential leakage in release builds. */ + var isEnabled: Boolean = false + fun debug(message: String) { - android.util.Log.d(TAG, message) + if (isEnabled) android.util.Log.d(TAG, message) } fun info(message: String) { - android.util.Log.i(TAG, message) + if (isEnabled) android.util.Log.i(TAG, message) } fun error(message: String) { - android.util.Log.e(TAG, message) + if (isEnabled) android.util.Log.e(TAG, message) } } From fed48ecce6db475cde3b3327bd19c5970da87393 Mon Sep 17 00:00:00 2001 From: Anthony Spriggs Date: Tue, 7 Apr 2026 13:32:41 -0400 Subject: [PATCH 2/4] Add GitHub Packages Maven publishing workflow - Apply maven-publish plugin to :sdk with release publication (com.idme:idme-auth-sdk) - Configure GitHubPackages repository using GITHUB_TOKEN - Add GROUP and VERSION_NAME to gradle.properties - Add publish.yml workflow triggered on GitHub Release or workflow_dispatch Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/publish.yml | 46 +++++++++++++++++++++++++++++++++++ gradle.properties | 4 +++ sdk/build.gradle.kts | 24 ++++++++++++++++++ 3 files changed, 74 insertions(+) create mode 100644 .github/workflows/publish.yml diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..bad825d --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,46 @@ +name: Publish to GitHub Packages + +on: + release: + types: [published] + workflow_dispatch: + inputs: + version: + description: 'Version to publish (e.g. 1.0.0). Leave blank to use gradle.properties VERSION_NAME.' + required: false + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v3 + + - name: Override version from workflow input + if: ${{ inputs.version != '' }} + run: sed -i "s/^VERSION_NAME=.*/VERSION_NAME=${{ inputs.version }}/" gradle.properties + + - name: Override version from release tag + if: ${{ github.event_name == 'release' }} + run: | + TAG="${{ github.event.release.tag_name }}" + VERSION="${TAG#v}" + sed -i "s/^VERSION_NAME=.*/VERSION_NAME=${VERSION}/" gradle.properties + + - name: Publish to GitHub Packages + env: + GITHUB_ACTOR: ${{ github.actor }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: ./gradlew :sdk:publishReleasePublicationToGitHubPackagesRepository diff --git a/gradle.properties b/gradle.properties index f0a2e55..60e9f9c 100644 --- a/gradle.properties +++ b/gradle.properties @@ -2,3 +2,7 @@ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 android.useAndroidX=true kotlin.code.style=official android.nonTransitiveRClass=true + +# SDK publishing +GROUP=com.idme +VERSION_NAME=0.1.0 diff --git a/sdk/build.gradle.kts b/sdk/build.gradle.kts index 248087d..7014d48 100644 --- a/sdk/build.gradle.kts +++ b/sdk/build.gradle.kts @@ -3,6 +3,7 @@ import com.android.build.gradle.LibraryExtension apply(plugin = "com.android.library") apply(plugin = "kotlin-android") apply(plugin = "kotlinx-serialization") +apply(plugin = "maven-publish") configure { namespace = "com.idme.auth" @@ -51,3 +52,26 @@ dependencies { "testImplementation"("junit:junit:4.13.2") "testImplementation"("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3") } + +afterEvaluate { + configure { + publications { + create("release") { + from(components["release"]) + groupId = project.findProperty("GROUP") as String + artifactId = "idme-auth-sdk" + version = project.findProperty("VERSION_NAME") as String + } + } + repositories { + maven { + name = "GitHubPackages" + url = uri("https://maven.pkg.github.com/IDme/android-auth-sample-code") + credentials { + username = System.getenv("GITHUB_ACTOR") + password = System.getenv("GITHUB_TOKEN") + } + } + } + } +} From 79b6c9f936e93e38ce80a0b9346b70eb50678892 Mon Sep 17 00:00:00 2001 From: Anthony Spriggs Date: Tue, 7 Apr 2026 13:45:49 -0400 Subject: [PATCH 3/4] Rename artifactId to android-auth-sample-code Aligns Maven coordinates with the repository name: me.id.auth:android-auth-sample-code: Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/release.yml | 18 +++++++++--------- sdk/build.gradle.kts | 2 +- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2233d49..a1f7fe9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -82,15 +82,15 @@ jobs: uses: actions/attest-build-provenance@v3 id: maven-attest with: - subject-path: ~/.m2/repository/me/id/auth/idme-auth-sample/${{ env.RELEASE_VERSION }}/* + subject-path: ~/.m2/repository/me/id/auth/android-auth-sample-code/${{ env.RELEASE_VERSION }}/* - name: Save attestation bundle alongside Maven artifacts run: | ATTESTATION_BUNDLE_PATH="${{ steps.maven-attest.outputs.bundle-path }}" - MAVEN_DIR=~/.m2/repository/me/id/auth/idme-auth-sample/$RELEASE_VERSION + MAVEN_DIR=~/.m2/repository/me/id/auth/android-auth-sample-code/$RELEASE_VERSION if [[ -f "$ATTESTATION_BUNDLE_PATH" ]]; then - cp "$ATTESTATION_BUNDLE_PATH" "$MAVEN_DIR/idme-auth-sample-${RELEASE_VERSION}.intoto.jsonl" - echo "Saved attestation bundle as idme-auth-sample-${RELEASE_VERSION}.intoto.jsonl" + cp "$ATTESTATION_BUNDLE_PATH" "$MAVEN_DIR/android-auth-sample-code-${RELEASE_VERSION}.intoto.jsonl" + echo "Saved attestation bundle as android-auth-sample-code-${RELEASE_VERSION}.intoto.jsonl" fi # --- Publish Maven artifacts + attestation to GitHub Packages --- @@ -99,7 +99,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | GROUP_ID="me.id.auth" - ARTIFACT_ID="idme-auth-sample" + ARTIFACT_ID="android-auth-sample-code" VERSION="$RELEASE_VERSION" GROUP_PATH=$(echo "$GROUP_ID" | tr '.' '/') GITHUB_URL="https://maven.pkg.github.com/IDme/android-auth-sample-code" @@ -171,10 +171,10 @@ jobs: continue-on-error: true run: | echo "Maven artifacts in local repository:" - ls -la ~/.m2/repository/me/id/auth/idme-auth-sample/$RELEASE_VERSION/ + ls -la ~/.m2/repository/me/id/auth/android-auth-sample-code/$RELEASE_VERSION/ echo "" echo "Generated POM content:" - cat ~/.m2/repository/me/id/auth/idme-auth-sample/$RELEASE_VERSION/idme-auth-sample-$RELEASE_VERSION.pom + cat ~/.m2/repository/me/id/auth/android-auth-sample-code/$RELEASE_VERSION/android-auth-sample-code-$RELEASE_VERSION.pom - name: Create Git tag run: | @@ -206,9 +206,9 @@ jobs: echo "- **Draft:** ${{ inputs.draft }}" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "### Maven Coordinates" >> $GITHUB_STEP_SUMMARY - echo "\`me.id.auth:idme-auth-sample:$RELEASE_VERSION\`" >> $GITHUB_STEP_SUMMARY + echo "\`me.id.auth:android-auth-sample-code:$RELEASE_VERSION\`" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "### Verification" >> $GITHUB_STEP_SUMMARY echo "\`\`\`bash" >> $GITHUB_STEP_SUMMARY - echo "gh attestation verify idme-auth-sample-$RELEASE_VERSION.aar --repo IDme/android-auth-sample-code" >> $GITHUB_STEP_SUMMARY + echo "gh attestation verify android-auth-sample-code-$RELEASE_VERSION.aar --repo IDme/android-auth-sample-code" >> $GITHUB_STEP_SUMMARY echo "\`\`\`" >> $GITHUB_STEP_SUMMARY diff --git a/sdk/build.gradle.kts b/sdk/build.gradle.kts index 482136a..3c79c8a 100644 --- a/sdk/build.gradle.kts +++ b/sdk/build.gradle.kts @@ -64,7 +64,7 @@ afterEvaluate { publications { register("release", MavenPublication::class) { groupId = "me.id.auth" - artifactId = "idme-auth-sample" + artifactId = "android-auth-sample-code" version = project.version.toString() from(components["release"]) From a9f123ee003ae219f95d3ee0079a646f4768dbea Mon Sep 17 00:00:00 2001 From: Anthony Spriggs Date: Tue, 14 Apr 2026 13:27:46 -0400 Subject: [PATCH 4/4] Add Sonatype Maven Central publishing support - Add Dokka plugin for Javadoc JAR generation (required by Sonatype) - Add sources JAR task (required by Sonatype) - Apply signing plugin with in-memory PGP key support for CI - Complete POM metadata: url, licenses, developers, and SCM (required by Sonatype) - Wire Dokka + nexus-publish plugin into root buildscript classpath - Configure Sonatype OSSRH staging repository via nexus-publish plugin - Add Sonatype publish step to release workflow using five new secrets: SONATYPE_USERNAME, SONATYPE_PASSWORD, SIGNING_KEY_ID, SIGNING_KEY, SIGNING_PASSWORD Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/release.yml | 16 +++++++++++ build.gradle.kts | 17 ++++++++++++ sdk/build.gradle.kts | 50 +++++++++++++++++++++++++++++++++++ 3 files changed, 83 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a1f7fe9..653203d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -176,6 +176,18 @@ jobs: echo "Generated POM content:" cat ~/.m2/repository/me/id/auth/android-auth-sample-code/$RELEASE_VERSION/android-auth-sample-code-$RELEASE_VERSION.pom + - name: Publish to Maven Central (Sonatype OSSRH) + env: + SONATYPE_USERNAME: ${{ secrets.SONATYPE_USERNAME }} + SONATYPE_PASSWORD: ${{ secrets.SONATYPE_PASSWORD }} + SIGNING_KEY_ID: ${{ secrets.SIGNING_KEY_ID }} + SIGNING_KEY: ${{ secrets.SIGNING_KEY }} + SIGNING_PASSWORD: ${{ secrets.SIGNING_PASSWORD }} + run: | + ./gradlew :sdk:publishReleasePublicationToSonatypeRepository \ + closeAndReleaseSonatypeStagingRepository \ + -PreleaseVersion=$RELEASE_VERSION + - name: Create Git tag run: | git config user.name "github-actions[bot]" @@ -208,6 +220,10 @@ jobs: echo "### Maven Coordinates" >> $GITHUB_STEP_SUMMARY echo "\`me.id.auth:android-auth-sample-code:$RELEASE_VERSION\`" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY + echo "### Published To" >> $GITHUB_STEP_SUMMARY + echo "- GitHub Packages: https://github.com/IDme/android-auth-sample-code/packages" >> $GITHUB_STEP_SUMMARY + echo "- Maven Central: https://central.sonatype.com/artifact/me.id.auth/android-auth-sample-code" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY echo "### Verification" >> $GITHUB_STEP_SUMMARY echo "\`\`\`bash" >> $GITHUB_STEP_SUMMARY echo "gh attestation verify android-auth-sample-code-$RELEASE_VERSION.aar --repo IDme/android-auth-sample-code" >> $GITHUB_STEP_SUMMARY diff --git a/build.gradle.kts b/build.gradle.kts index 59573f4..b6fae28 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,3 +1,5 @@ +import io.github.gradlenexus.publishplugin.NexusPublishExtension + buildscript { repositories { google() @@ -8,5 +10,20 @@ buildscript { classpath("com.android.tools.build:gradle:8.2.2") classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.22") classpath("org.jetbrains.kotlin:kotlin-serialization:1.9.22") + classpath("org.jetbrains.dokka:dokka-gradle-plugin:1.9.20") + classpath("io.github.gradle-nexus:publish-plugin:2.0.0") + } +} + +apply(plugin = "io.github.gradle-nexus.publish-plugin") + +configure { + repositories { + sonatype { + nexusUrl.set(uri("https://s01.oss.sonatype.org/service/local/")) + snapshotRepositoryUrl.set(uri("https://s01.oss.sonatype.org/content/repositories/snapshots/")) + username.set(findProperty("sonatypeUsername")?.toString() ?: System.getenv("SONATYPE_USERNAME")) + password.set(findProperty("sonatypePassword")?.toString() ?: System.getenv("SONATYPE_PASSWORD")) + } } } diff --git a/sdk/build.gradle.kts b/sdk/build.gradle.kts index 3c79c8a..4947444 100644 --- a/sdk/build.gradle.kts +++ b/sdk/build.gradle.kts @@ -1,11 +1,14 @@ import com.android.build.gradle.LibraryExtension import org.gradle.api.publish.PublishingExtension import org.gradle.api.publish.maven.MavenPublication +import org.gradle.plugins.signing.SigningExtension apply(plugin = "com.android.library") apply(plugin = "kotlin-android") apply(plugin = "kotlinx-serialization") apply(plugin = "maven-publish") +apply(plugin = "signing") +apply(plugin = "org.jetbrains.dokka") version = findProperty("releaseVersion")?.toString() ?: "1.0.0" @@ -48,6 +51,17 @@ tasks.withType { } } +val sourcesJar by tasks.registering(Jar::class) { + archiveClassifier.set("sources") + from("src/main/java", "src/main/kotlin") +} + +val javadocJar by tasks.registering(Jar::class) { + archiveClassifier.set("javadoc") + dependsOn(tasks.named("dokkaJavadoc")) + from(tasks.named("dokkaJavadoc").map { it.outputs.files }) +} + dependencies { "implementation"("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") "implementation"("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.2") @@ -68,11 +82,36 @@ afterEvaluate { version = project.version.toString() from(components["release"]) + artifact(sourcesJar) + artifact(javadocJar) pom { name.set("ID.me Auth Sample Code") description.set("ID.me Android Auth Sample Code SDK") + url.set("https://github.com/IDme/android-auth-sample-code") packaging = "aar" + + licenses { + license { + name.set("MIT License") + url.set("https://opensource.org/licenses/MIT") + distribution.set("repo") + } + } + + developers { + developer { + id.set("idme") + name.set("ID.me") + email.set("engineering@id.me") + } + } + + scm { + connection.set("scm:git:git://github.com/IDme/android-auth-sample-code.git") + developerConnection.set("scm:git:ssh://github.com/IDme/android-auth-sample-code.git") + url.set("https://github.com/IDme/android-auth-sample-code") + } } } } @@ -81,4 +120,15 @@ afterEvaluate { mavenLocal() } } + + configure { + val signingKeyId = findProperty("signingKeyId")?.toString() ?: System.getenv("SIGNING_KEY_ID") + val signingKey = findProperty("signingKey")?.toString() ?: System.getenv("SIGNING_KEY") + val signingPassword = findProperty("signingPassword")?.toString() ?: System.getenv("SIGNING_PASSWORD") + + if (!signingKey.isNullOrBlank() && !signingPassword.isNullOrBlank()) { + useInMemoryPgpKeys(signingKeyId, signingKey, signingPassword) + sign(extensions.getByType().publications["release"]) + } + } }