diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..310628a --- /dev/null +++ b/.editorconfig @@ -0,0 +1,2 @@ +[*.{kt,kts}] +ktlint_function_naming_ignore_when_annotated_with = Composable diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..be44332 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,88 @@ +# ThunderID Android SDK — Agent Instructions + +## Project overview + +Kotlin Android library providing the ThunderID authentication SDK (`dev.thunderid.android`) and a Jetpack Compose component library (`dev.thunderid.compose`). The `samples/quickstart` directory contains a standalone demo app. + +## Build & test + +```bash +# Build the SDK library +./gradlew build + +# Run unit tests +./gradlew test + +# Run lint and ktlint checks (must pass before any PR) +./gradlew lint ktlintCheck +``` + +## Ktlint compliance (required) + +All code **must pass `ktlintCheck` with zero violations**. The CI `lint` job runs this check on every PR and treats any violation as a build failure. + +Configuration: ktlint 12.1.1 via the Gradle plugin. Key rules enforced: + +| Rule | What to do | +|---|---| +| `indent` | Use 4-space indentation, no tabs. | +| `max-line-length` | Keep lines ≤ 120 characters. Wrap long function signatures and call-sites. | +| `import-ordering` | Sort imports alphabetically; no wildcard imports. | +| `no-trailing-whitespace` | No trailing spaces on any line. | +| `trailing-comma-on-call-site` | No trailing comma on the last argument at call sites. | +| `trailing-comma-on-declaration-site` | Trailing comma required on the last element of multi-line declarations. | +| `final-newline` | Every file must end with exactly one newline character. | + +### Practical checklist before finishing any change + +1. Run `./gradlew ktlintCheck` locally and fix every reported violation. +2. Keep all lines ≤ 120 characters; wrap function signatures with one parameter per line when needed. +3. Sort import statements alphabetically; remove unused imports. +4. Use trailing commas on multi-line declaration sites (function parameters, enum entries, etc.). +5. Put `else`/`catch` on the same line as the closing `}`: `} else {`. +6. If a new file grows past ~400 lines, consider splitting it. + +## File layout + +``` +src/main/kotlin/dev/thunderid/ + android/ Core SDK (auth, token, http layers) + auth/ PKCE + flow execution client + http/ HTTP client + token/ Token store, validator, refresher, JWKS cache + compose/ Jetpack Compose component library + components/ + actions/ SignInButton, SignOutButton, SignUpButton + guards/ SignedIn, SignedOut, Loading + flow/ Callback (OAuth2 redirect handler) + presentation/ + auth/ SignIn, SignUp forms + organization/ LanguageSwitcher + user/ User, UserDropdown, UserProfile + i18n/ Localization (ThunderI18n, DefaultStrings) +src/test/kotlin/ Unit tests +samples/quickstart/ Demo app (not part of the SDK) +build.gradle.kts SDK library build config +settings.gradle.kts Project settings +``` + +## Architecture + +- **Layer 1–2** (`dev.thunderid.android`): platform SDK — HTTP, token management, auth flows, PKCE, storage. +- **Layer 3** (`dev.thunderid.compose`): Compose UI wrappers around the platform SDK. +- **Layer 4** (`samples/quickstart`): standalone demo app that depends on the SDK via `includeBuild`. + +Every Compose component ships in two variants: +- **Styled** — opinionated Material 3 defaults (e.g. `SignInButton`). +- **Base** — unstyled slot-based variant for full customization (e.g. `BaseSignInButton`). + +## Code style + +- Kotlin, target JVM 17; minSdk 26 for the SDK, minSdk 24 for the sample. +- Jetpack Compose with BOM `2024.02.00`; Kotlin Compose compiler extension `1.5.8`. +- No external DI framework — use `CompositionLocal` (`LocalThunder`) for state access. +- Use coroutines (`suspend`/`Flow`) over callbacks. +- Mark internal helpers `private` or `internal`; expose only intentional API as `public`. +- No third-party networking — `HttpClient` wraps `HttpURLConnection` directly. +- Token storage uses `EncryptedSharedPreferences` (Android Keystore / AES256-GCM) via the `StorageAdapter` interface. +- Error handling uses typed `IAMErrorCode` enum (39 codes) and `IAMException`. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..eef4bd2 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..13372ae Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..a80b22c --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..9d82f78 --- /dev/null +++ b/gradlew @@ -0,0 +1,160 @@ +#!/usr/bin/env bash + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn ( ) { + echo "$*" +} + +die ( ) { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; +esac + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules +function splitJvmOpts() { + JVM_OPTS=("$@") +} +eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS +JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" + +exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..8a0b282 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,90 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windowz variants + +if not "%OS%" == "Windows_NT" goto win9xME_args +if "%@eval[2+2]" == "4" goto 4NT_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* +goto execute + +:4NT_args +@rem Get arguments from the 4NT Shell from JP Software +set CMD_LINE_ARGS=%$ + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/samples/quickstart/build.gradle.kts b/samples/quickstart/build.gradle.kts index ff6a194..8699ca3 100644 --- a/samples/quickstart/build.gradle.kts +++ b/samples/quickstart/build.gradle.kts @@ -9,7 +9,7 @@ android { defaultConfig { applicationId = "dev.thunderid.Quickstart" - minSdk = 24 + minSdk = 26 targetSdk = 34 versionCode = 1 versionName = "1.0.0" @@ -36,6 +36,11 @@ android { kotlinCompilerExtensionVersion = "1.5.8" } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { jvmTarget = "17" } diff --git a/samples/quickstart/gradle.properties b/samples/quickstart/gradle.properties new file mode 100644 index 0000000..9881f6c --- /dev/null +++ b/samples/quickstart/gradle.properties @@ -0,0 +1,2 @@ +android.useAndroidX=true +org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError diff --git a/samples/quickstart/gradle/wrapper/gradle-wrapper.jar b/samples/quickstart/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..13372ae Binary files /dev/null and b/samples/quickstart/gradle/wrapper/gradle-wrapper.jar differ diff --git a/samples/quickstart/gradle/wrapper/gradle-wrapper.properties b/samples/quickstart/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..a80b22c --- /dev/null +++ b/samples/quickstart/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/samples/quickstart/gradlew b/samples/quickstart/gradlew new file mode 100755 index 0000000..9d82f78 --- /dev/null +++ b/samples/quickstart/gradlew @@ -0,0 +1,160 @@ +#!/usr/bin/env bash + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn ( ) { + echo "$*" +} + +die ( ) { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; +esac + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules +function splitJvmOpts() { + JVM_OPTS=("$@") +} +eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS +JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" + +exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" diff --git a/samples/quickstart/settings.gradle.kts b/samples/quickstart/settings.gradle.kts index bb8aa81..d48764d 100644 --- a/samples/quickstart/settings.gradle.kts +++ b/samples/quickstart/settings.gradle.kts @@ -1,2 +1,22 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } + plugins { + id("com.android.application") version "8.2.2" + id("org.jetbrains.kotlin.android") version "1.9.22" + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + rootProject.name = "AndroidQuickstart" includeBuild("../..") { name = "android" } diff --git a/samples/quickstart/src/main/kotlin/dev/thunderid/quickstart/AuthScreen.kt b/samples/quickstart/src/main/kotlin/dev/thunderid/quickstart/AuthScreen.kt index 7addb5c..ba80993 100644 --- a/samples/quickstart/src/main/kotlin/dev/thunderid/quickstart/AuthScreen.kt +++ b/samples/quickstart/src/main/kotlin/dev/thunderid/quickstart/AuthScreen.kt @@ -1,3 +1,21 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + package dev.thunderid.quickstart import androidx.compose.foundation.background @@ -13,6 +31,7 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Home +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SegmentedButton @@ -32,6 +51,7 @@ import dev.thunderid.compose.components.presentation.auth.SignUp private val authModes = listOf("Sign In", "Create Account") +@OptIn(ExperimentalMaterial3Api::class) @Composable fun AuthScreen(applicationId: String) { val cs = MaterialTheme.colorScheme diff --git a/samples/quickstart/src/main/kotlin/dev/thunderid/quickstart/HomeScreen.kt b/samples/quickstart/src/main/kotlin/dev/thunderid/quickstart/HomeScreen.kt index cb44818..81eb885 100644 --- a/samples/quickstart/src/main/kotlin/dev/thunderid/quickstart/HomeScreen.kt +++ b/samples/quickstart/src/main/kotlin/dev/thunderid/quickstart/HomeScreen.kt @@ -1,3 +1,21 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + package dev.thunderid.quickstart import android.graphics.BitmapFactory diff --git a/samples/quickstart/src/main/kotlin/dev/thunderid/quickstart/MainActivity.kt b/samples/quickstart/src/main/kotlin/dev/thunderid/quickstart/MainActivity.kt index 7e6461d..fd8dc6c 100644 --- a/samples/quickstart/src/main/kotlin/dev/thunderid/quickstart/MainActivity.kt +++ b/samples/quickstart/src/main/kotlin/dev/thunderid/quickstart/MainActivity.kt @@ -1,10 +1,27 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + package dev.thunderid.quickstart import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.dynamicColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.ui.graphics.Color import dev.thunderid.android.ThunderIDConfig diff --git a/samples/quickstart/src/main/kotlin/dev/thunderid/quickstart/RootView.kt b/samples/quickstart/src/main/kotlin/dev/thunderid/quickstart/RootView.kt index 940bbfa..e4280cf 100644 --- a/samples/quickstart/src/main/kotlin/dev/thunderid/quickstart/RootView.kt +++ b/samples/quickstart/src/main/kotlin/dev/thunderid/quickstart/RootView.kt @@ -1,3 +1,21 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + package dev.thunderid.quickstart import androidx.compose.foundation.layout.Box diff --git a/settings.gradle.kts b/settings.gradle.kts index 89c46a2..6174c87 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,4 +1,12 @@ -rootProject.name = "thunderid-android" +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +rootProject.name = "android" dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) diff --git a/src/main/kotlin/dev/thunderid/android/Models.kt b/src/main/kotlin/dev/thunderid/android/Models.kt index b633561..2818ddb 100644 --- a/src/main/kotlin/dev/thunderid/android/Models.kt +++ b/src/main/kotlin/dev/thunderid/android/Models.kt @@ -1,3 +1,21 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + package dev.thunderid.android import com.google.gson.annotations.SerializedName @@ -9,12 +27,12 @@ data class User( val displayName: String? = null, @SerializedName("picture") val profilePicture: String? = null, val isNewUser: Boolean? = null, - val claims: Map? = null + val claims: Map? = null, ) data class UserProfile( val id: String, - val claims: Map = emptyMap() + val claims: Map = emptyMap(), ) data class TokenResponse( @@ -23,50 +41,50 @@ data class TokenResponse( @SerializedName("expires_in") val expiresIn: Int? = null, @SerializedName("refresh_token") val refreshToken: String? = null, @SerializedName("id_token") val idToken: String? = null, - val scope: String? = null + val scope: String? = null, ) data class SignInOptions( val prompt: String? = null, val loginHint: String? = null, val fidp: String? = null, - val extra: Map = emptyMap() + val extra: Map = emptyMap(), ) data class SignUpOptions( val appId: String? = null, - val extra: Map = emptyMap() + val extra: Map = emptyMap(), ) data class SignOutOptions( val idTokenHint: String? = null, - val extra: Map = emptyMap() + val extra: Map = emptyMap(), ) data class TokenExchangeRequestConfig( val subjectToken: String, val subjectTokenType: String, val requestedTokenType: String? = null, - val audience: String? = null + val audience: String? = null, ) data class EmbeddedSignInPayload( val flowId: String? = null, val actionId: String, val inputs: Map = emptyMap(), - val challengeToken: String? = null + val challengeToken: String? = null, ) data class EmbeddedFlowRequestConfig( val applicationId: String, - val flowType: FlowType = FlowType.AUTHENTICATION + val flowType: FlowType = FlowType.AUTHENTICATION, ) enum class FlowType(val value: String) { AUTHENTICATION("AUTHENTICATION"), REGISTRATION("REGISTRATION"), PASSWORD_RECOVERY("PASSWORD_RECOVERY"), - INVITED_USER_REGISTRATION("INVITED_USER_REGISTRATION") + INVITED_USER_REGISTRATION("INVITED_USER_REGISTRATION"), } data class EmbeddedFlowResponse( @@ -77,7 +95,7 @@ data class EmbeddedFlowResponse( val data: FlowStepData? = null, val assertion: String? = null, val failureReason: String? = null, - val challengeToken: String? = null + val challengeToken: String? = null, ) enum class FlowStatus { PROMPT_ONLY, INCOMPLETE, COMPLETE, ERROR } @@ -85,7 +103,7 @@ enum class FlowStatus { PROMPT_ONLY, INCOMPLETE, COMPLETE, ERROR } data class FlowStepData( val actions: List? = null, val inputs: List? = null, - val meta: Map? = null + val meta: Map? = null, ) data class FlowAction( @@ -93,11 +111,11 @@ data class FlowAction( val ref: String? = null, val nextNode: String? = null, val type: String? = null, - val label: String? = null + val label: String? = null, ) data class FlowInput( @SerializedName("identifier") val name: String, val type: String? = null, - val required: Boolean? = null + val required: Boolean? = null, ) diff --git a/src/main/kotlin/dev/thunderid/android/StorageAdapter.kt b/src/main/kotlin/dev/thunderid/android/StorageAdapter.kt index 203e6ea..1873a10 100644 --- a/src/main/kotlin/dev/thunderid/android/StorageAdapter.kt +++ b/src/main/kotlin/dev/thunderid/android/StorageAdapter.kt @@ -1,3 +1,21 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + package dev.thunderid.android import android.content.Context @@ -8,9 +26,15 @@ import androidx.security.crypto.MasterKey * Interface for custom token/session storage backends (spec §11.1). */ interface StorageAdapter { - fun store(key: String, value: String) + fun store( + key: String, + value: String, + ) + fun retrieve(key: String): String? + fun delete(key: String) + fun clear() } @@ -18,20 +42,25 @@ interface StorageAdapter { * Default storage using Android EncryptedSharedPreferences backed by the Android Keystore (spec §11.1). */ class EncryptedStorageAdapter(context: Context) : StorageAdapter { - private val prefs = run { - val masterKey = MasterKey.Builder(context) - .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) - .build() - EncryptedSharedPreferences.create( - context, - "dev.thunderid.sdk.prefs", - masterKey, - EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, - EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM - ) - } + private val prefs = + run { + val masterKey = + MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + EncryptedSharedPreferences.create( + context, + "dev.thunderid.sdk.prefs", + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, + ) + } - override fun store(key: String, value: String) { + override fun store( + key: String, + value: String, + ) { prefs.edit().putString(key, value).apply() } @@ -52,8 +81,20 @@ class EncryptedStorageAdapter(context: Context) : StorageAdapter { class InMemoryStorageAdapter : StorageAdapter { private val store = mutableMapOf() - override fun store(key: String, value: String) { store[key] = value } + override fun store( + key: String, + value: String, + ) { + store[key] = value + } + override fun retrieve(key: String): String? = store[key] - override fun delete(key: String) { store.remove(key) } - override fun clear() { store.clear() } + + override fun delete(key: String) { + store.remove(key) + } + + override fun clear() { + store.clear() + } } diff --git a/src/main/kotlin/dev/thunderid/android/ThunderIDClient.kt b/src/main/kotlin/dev/thunderid/android/ThunderIDClient.kt index aaceb5d..0b48caa 100644 --- a/src/main/kotlin/dev/thunderid/android/ThunderIDClient.kt +++ b/src/main/kotlin/dev/thunderid/android/ThunderIDClient.kt @@ -1,3 +1,21 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + package dev.thunderid.android import dev.thunderid.android.auth.FlowExecutionClient @@ -19,17 +37,24 @@ class ThunderIDClient { private var tokenValidator: TokenValidator? = null private var flowClient: FlowExecutionClient? = null private val pkceManager = PKCEManager() - private var _isLoading = false + private var loading = false private var currentUser: User? = null // MARK: - Lifecycle - suspend fun initialize(config: ThunderIDConfig, storage: StorageAdapter? = null): Boolean { - if (this.config != null) throw IAMException(IAMErrorCode.ALREADY_INITIALIZED, "SDK is already initialized") + suspend fun initialize( + config: ThunderIDConfig, + storage: StorageAdapter? = null, + ): Boolean { + if (this.config != null) throw IAMException(ThunderIDErrorCode.ALREADY_INITIALIZED, "SDK is already initialized") validateConfig(config) this.config = config - val adapter = storage ?: config.storage - ?: throw IAMException(IAMErrorCode.INVALID_CONFIGURATION, "A StorageAdapter is required on Android; pass an EncryptedStorageAdapter(context)") + val adapter = + storage ?: config.storage + ?: throw IAMException( + ThunderIDErrorCode.INVALID_CONFIGURATION, + "A StorageAdapter is required on Android; pass an EncryptedStorageAdapter(context)", + ) val http = HttpClient(config.baseUrl) val store = TokenStore(adapter) val jwks = JWKSCache(http) @@ -38,56 +63,65 @@ class ThunderIDClient { tokenRefresher = TokenRefresher(http, store) flowClient = FlowExecutionClient(http) http.setAccessTokenProvider { - val clientId = this.config?.clientId - ?: throw IAMException(IAMErrorCode.INVALID_CONFIGURATION, "clientId required") + val clientId = + this.config?.clientId + ?: throw IAMException(ThunderIDErrorCode.INVALID_CONFIGURATION, "clientId required") tokenRefresher!!.getAccessToken(clientId) } httpClient = http return true } - suspend fun reInitialize(baseUrl: String? = null, clientId: String? = null): Boolean { - val current = config ?: throw IAMException(IAMErrorCode.SDK_NOT_INITIALIZED, "SDK not initialized") - val updated = current.copy( - baseUrl = baseUrl ?: current.baseUrl, - clientId = clientId ?: current.clientId - ) + suspend fun reInitialize( + baseUrl: String? = null, + clientId: String? = null, + ): Boolean { + val current = config ?: throw IAMException(ThunderIDErrorCode.SDK_NOT_INITIALIZED, "SDK not initialized") + val updated = + current.copy( + baseUrl = baseUrl ?: current.baseUrl, + clientId = clientId ?: current.clientId, + ) this.config = null return initialize(updated, tokenStore?.let { null }) // storage is already set internally } - fun getConfiguration(): ThunderIDConfig = - config ?: throw IAMException(IAMErrorCode.SDK_NOT_INITIALIZED, "SDK not initialized") + fun getConfiguration(): ThunderIDConfig = config ?: throw IAMException(ThunderIDErrorCode.SDK_NOT_INITIALIZED, "SDK not initialized") // MARK: - Authentication - suspend fun signIn(payload: EmbeddedSignInPayload, request: EmbeddedFlowRequestConfig): EmbeddedFlowResponse { + suspend fun signIn( + payload: EmbeddedSignInPayload, + request: EmbeddedFlowRequestConfig, + ): EmbeddedFlowResponse { requireInitialized() - _isLoading = true + loading = true return try { - val response = if (payload.flowId != null) { - flowClient!!.submit(payload.flowId, payload.actionId, payload.inputs, payload.challengeToken) - } else { - flowClient!!.initiate(request.applicationId, request.flowType) - } + val response = + if (payload.flowId != null) { + flowClient!!.submit(payload.flowId, payload.actionId, payload.inputs, payload.challengeToken) + } else { + flowClient!!.initiate(request.applicationId, request.flowType) + } establishSessionIfNeeded(response) response } finally { - _isLoading = false + loading = false } } fun buildSignInUrl(options: SignInOptions? = null): String { val cfg = requireConfig() - val clientId = cfg.clientId ?: throw IAMException(IAMErrorCode.INVALID_CONFIGURATION, "clientId required for redirect mode") + val clientId = cfg.clientId ?: throw IAMException(ThunderIDErrorCode.INVALID_CONFIGURATION, "clientId required for redirect mode") val (_, challenge) = pkceManager.generate() - val params = StringBuilder("${cfg.baseUrl}/oauth2/authorize") - .append("?response_type=code") - .append("&client_id=").append(clientId) - .append("&redirect_uri=").append(cfg.afterSignInUrl ?: "") - .append("&scope=").append(cfg.scopes.joinToString(" ")) - .append("&code_challenge=").append(challenge) - .append("&code_challenge_method=S256") + val params = + StringBuilder("${cfg.baseUrl}/oauth2/authorize") + .append("?response_type=code") + .append("&client_id=").append(clientId) + .append("&redirect_uri=").append(cfg.afterSignInUrl ?: "") + .append("&scope=").append(cfg.scopes.joinToString(" ")) + .append("&code_challenge=").append(challenge) + .append("&code_challenge_method=S256") options?.prompt?.let { params.append("&prompt=").append(it) } options?.loginHint?.let { params.append("&login_hint=").append(it) } options?.fidp?.let { params.append("&fidp=").append(it) } @@ -96,19 +130,22 @@ class ThunderIDClient { suspend fun handleRedirectCallback(url: String): User { val cfg = requireConfig() - val clientId = cfg.clientId ?: throw IAMException(IAMErrorCode.INVALID_CONFIGURATION, "clientId required") - val code = url.substringAfter("code=").substringBefore("&").takeIf { it.isNotEmpty() } - ?: throw IAMException(IAMErrorCode.INVALID_GRANT, "Authorization code missing from callback URL") - val verifier = pkceManager.codeVerifier - ?: throw IAMException(IAMErrorCode.INVALID_GRANT, "PKCE verifier not found") + val clientId = cfg.clientId ?: throw IAMException(ThunderIDErrorCode.INVALID_CONFIGURATION, "clientId required") + val code = + url.substringAfter("code=").substringBefore("&").takeIf { it.isNotEmpty() } + ?: throw IAMException(ThunderIDErrorCode.INVALID_GRANT, "Authorization code missing from callback URL") + val verifier = + pkceManager.codeVerifier + ?: throw IAMException(ThunderIDErrorCode.INVALID_GRANT, "PKCE verifier not found") pkceManager.clearVerifier() - val body = mapOf( - "grant_type" to "authorization_code", - "code" to code, - "client_id" to clientId, - "redirect_uri" to (cfg.afterSignInUrl ?: ""), - "code_verifier" to verifier - ) + val body = + mapOf( + "grant_type" to "authorization_code", + "code" to code, + "client_id" to clientId, + "redirect_uri" to (cfg.afterSignInUrl ?: ""), + "code_verifier" to verifier, + ) val tokenResponse: TokenResponse = httpClient!!.post("/oauth2/token", body, requiresAuth = false) tokenResponse.idToken?.let { tokenValidator?.validate(it, null) } tokenStore!!.save(tokenResponse) @@ -117,7 +154,7 @@ class ThunderIDClient { suspend fun signOut(options: SignOutOptions? = null): String { requireInitialized() - _isLoading = true + loading = true return try { val refreshToken = tokenStore?.refreshToken() val clientId = config?.clientId @@ -129,7 +166,7 @@ class ThunderIDClient { currentUser = null config?.afterSignOutUrl ?: "/" } finally { - _isLoading = false + loading = false } } @@ -138,18 +175,22 @@ class ThunderIDClient { return tokenStore?.accessToken() != null } - fun isLoading(): Boolean = _isLoading + fun isLoading(): Boolean = loading // MARK: - Registration - suspend fun signUp(payload: EmbeddedSignInPayload? = null, request: EmbeddedFlowRequestConfig? = null): EmbeddedFlowResponse { + suspend fun signUp( + payload: EmbeddedSignInPayload? = null, + request: EmbeddedFlowRequestConfig? = null, + ): EmbeddedFlowResponse { requireInitialized() val appId = request?.applicationId ?: config?.applicationId ?: "" - val response = if (payload?.flowId != null) { - flowClient!!.submit(payload.flowId, payload.actionId, payload.inputs, payload.challengeToken) - } else { - flowClient!!.initiate(appId, request?.flowType ?: FlowType.REGISTRATION) - } + val response = + if (payload?.flowId != null) { + flowClient!!.submit(payload.flowId, payload.actionId, payload.inputs, payload.challengeToken) + } else { + flowClient!!.initiate(appId, request?.flowType ?: FlowType.REGISTRATION) + } establishSessionIfNeeded(response) return response } @@ -158,16 +199,17 @@ class ThunderIDClient { suspend fun getAccessToken(): String { requireInitialized() - val clientId = config?.clientId ?: throw IAMException(IAMErrorCode.INVALID_CONFIGURATION, "clientId required") + val clientId = config?.clientId ?: throw IAMException(ThunderIDErrorCode.INVALID_CONFIGURATION, "clientId required") return tokenRefresher!!.getAccessToken(clientId) } fun decodeJwtToken(token: String): Map { val parts = token.split(".") - if (parts.size != 3) throw IAMException(IAMErrorCode.INVALID_INPUT, "Invalid JWT format") - val padded = parts[1].replace('-', '+').replace('_', '/').let { - it + "=".repeat((4 - it.length % 4) % 4) - } + if (parts.size != 3) throw IAMException(ThunderIDErrorCode.INVALID_INPUT, "Invalid JWT format") + val padded = + parts[1].replace('-', '+').replace('_', '/').let { + it + "=".repeat((4 - it.length % 4) % 4) + } val json = String(android.util.Base64.decode(padded, android.util.Base64.DEFAULT), Charsets.UTF_8) return org.json.JSONObject(json).let { obj -> obj.keys().asSequence().associateWith { obj.opt(it) } @@ -176,11 +218,12 @@ class ThunderIDClient { suspend fun exchangeToken(config: TokenExchangeRequestConfig): TokenResponse { requireInitialized() - val body = mutableMapOf( - "grant_type" to "urn:ietf:params:oauth:grant-type:token-exchange", - "subject_token" to config.subjectToken, - "subject_token_type" to config.subjectTokenType - ) + val body = + mutableMapOf( + "grant_type" to "urn:ietf:params:oauth:grant-type:token-exchange", + "subject_token" to config.subjectToken, + "subject_token_type" to config.subjectTokenType, + ) (this.config?.clientId ?: this.config?.applicationId)?.takeIf { it.isNotEmpty() }?.let { body["client_id"] = it } config.requestedTokenType?.let { body["requested_token_type"] = it } config.audience?.let { body["audience"] = it } @@ -209,7 +252,10 @@ class ThunderIDClient { return httpClient!!.get("/scim2/Me") } - suspend fun updateUserProfile(payload: Map, userId: String? = null): User { + suspend fun updateUserProfile( + payload: Map, + userId: String? = null, + ): User { requireInitialized() val path = if (userId != null) "/scim2/Users/$userId" else "/scim2/Me" val updated: User = httpClient!!.post(path, payload) @@ -219,7 +265,10 @@ class ThunderIDClient { // MARK: - Flow Meta - suspend fun getFlowMeta(applicationId: String, language: String = "en-US"): Map { + suspend fun getFlowMeta( + applicationId: String, + language: String = "en-US", + ): Map { requireInitialized() val path = "/flow/meta?id=$applicationId&type=APP&language=$language" val json: com.google.gson.JsonObject = httpClient!!.get(path, requiresAuth = false) @@ -230,15 +279,15 @@ class ThunderIDClient { // MARK: - Private helpers private fun requireInitialized() { - config ?: throw IAMException(IAMErrorCode.SDK_NOT_INITIALIZED, "Call initialize() before using the SDK") + config ?: throw IAMException(ThunderIDErrorCode.SDK_NOT_INITIALIZED, "Call initialize() before using the SDK") } private fun requireConfig(): ThunderIDConfig = - config ?: throw IAMException(IAMErrorCode.SDK_NOT_INITIALIZED, "Call initialize() before using the SDK") + config ?: throw IAMException(ThunderIDErrorCode.SDK_NOT_INITIALIZED, "Call initialize() before using the SDK") private fun validateConfig(config: ThunderIDConfig) { - if (config.baseUrl.isEmpty()) throw IAMException(IAMErrorCode.INVALID_CONFIGURATION, "baseUrl is required") - if (!config.baseUrl.startsWith("https://")) throw IAMException(IAMErrorCode.INVALID_CONFIGURATION, "baseUrl must use HTTPS") + if (config.baseUrl.isEmpty()) throw IAMException(ThunderIDErrorCode.INVALID_CONFIGURATION, "baseUrl is required") + if (!config.baseUrl.startsWith("https://")) throw IAMException(ThunderIDErrorCode.INVALID_CONFIGURATION, "baseUrl must use HTTPS") } private suspend fun establishSessionIfNeeded(response: EmbeddedFlowResponse) { @@ -250,8 +299,8 @@ class ThunderIDClient { exchangeToken( TokenExchangeRequestConfig( subjectToken = assertion, - subjectTokenType = "urn:ietf:params:oauth:token-type:jwt" - ) + subjectTokenType = "urn:ietf:params:oauth:token-type:jwt", + ), ) } } diff --git a/src/main/kotlin/dev/thunderid/android/ThunderIDConfig.kt b/src/main/kotlin/dev/thunderid/android/ThunderIDConfig.kt index 2215ed9..35acba8 100644 --- a/src/main/kotlin/dev/thunderid/android/ThunderIDConfig.kt +++ b/src/main/kotlin/dev/thunderid/android/ThunderIDConfig.kt @@ -1,3 +1,21 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + package dev.thunderid.android /** @@ -7,34 +25,29 @@ data class ThunderIDConfig( // Core val baseUrl: String, val clientId: String? = null, - // Redirect URIs val afterSignInUrl: String? = null, val afterSignOutUrl: String? = null, val signInUrl: String? = null, val signUpUrl: String? = null, - // OAuth2 / OIDC val scopes: List = listOf("openid"), val clientSecret: String? = null, val signInOptions: Map = emptyMap(), val signOutOptions: Map = emptyMap(), val signUpOptions: Map = emptyMap(), - // Application Identity val applicationId: String? = null, val organizationHandle: String? = null, - // Token Validation val tokenValidation: TokenValidationConfig = TokenValidationConfig(), - // Storage & Platform val storage: StorageAdapter? = null, - val instanceId: Int? = null + val instanceId: Int? = null, ) data class TokenValidationConfig( val validate: Boolean = true, val validateIssuer: Boolean = true, - val clockTolerance: Int = 0 + val clockTolerance: Int = 0, ) diff --git a/src/main/kotlin/dev/thunderid/android/IAMError.kt b/src/main/kotlin/dev/thunderid/android/ThunderIDError.kt similarity index 59% rename from src/main/kotlin/dev/thunderid/android/IAMError.kt rename to src/main/kotlin/dev/thunderid/android/ThunderIDError.kt index 682b8fc..12f9c51 100644 --- a/src/main/kotlin/dev/thunderid/android/IAMError.kt +++ b/src/main/kotlin/dev/thunderid/android/ThunderIDError.kt @@ -1,9 +1,27 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + package dev.thunderid.android /** * Typed error codes for all ThunderID SDK error conditions (spec §10.2). */ -enum class IAMErrorCode(val value: String) { +enum class ThunderIDErrorCode(val value: String) { // Configuration SDK_NOT_INITIALIZED("SDK_NOT_INITIALIZED"), ALREADY_INITIALIZED("ALREADY_INITIALIZED"), @@ -36,16 +54,16 @@ enum class IAMErrorCode(val value: String) { NETWORK_ERROR("NETWORK_ERROR"), REQUEST_TIMEOUT("REQUEST_TIMEOUT"), SERVER_ERROR("SERVER_ERROR"), - UNKNOWN_ERROR("UNKNOWN_ERROR"); + UNKNOWN_ERROR("UNKNOWN_ERROR"), + ; companion object { - fun fromValue(value: String): IAMErrorCode = - entries.firstOrNull { it.value == value } ?: UNKNOWN_ERROR + fun fromValue(value: String): ThunderIDErrorCode = entries.firstOrNull { it.value == value } ?: UNKNOWN_ERROR } } class IAMException( - val code: IAMErrorCode, + val code: ThunderIDErrorCode, message: String, - cause: Throwable? = null + cause: Throwable? = null, ) : Exception("[$code] $message", cause) diff --git a/src/main/kotlin/dev/thunderid/android/auth/FlowExecutionClient.kt b/src/main/kotlin/dev/thunderid/android/auth/FlowExecutionClient.kt index 62ea9e2..b6d3985 100644 --- a/src/main/kotlin/dev/thunderid/android/auth/FlowExecutionClient.kt +++ b/src/main/kotlin/dev/thunderid/android/auth/FlowExecutionClient.kt @@ -1,3 +1,21 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + package dev.thunderid.android.auth import dev.thunderid.android.EmbeddedFlowResponse @@ -8,24 +26,36 @@ import dev.thunderid.android.http.HttpClient * Drives the ThunderID Flow Execution API for app-native sign-in, sign-up, and recovery (spec §6.1–6.3). */ internal class FlowExecutionClient(private val httpClient: HttpClient) { - - suspend fun initiate(applicationId: String, flowType: FlowType): EmbeddedFlowResponse { - val body = mapOf( - "applicationId" to applicationId, - "flowType" to flowType.value, - "verbose" to true - ) + suspend fun initiate( + applicationId: String, + flowType: FlowType, + ): EmbeddedFlowResponse { + val body = + mapOf( + "applicationId" to applicationId, + "flowType" to flowType.value, + "verbose" to true, + ) return httpClient.post("/flow/execute", body, requiresAuth = false) } - suspend fun submit(flowId: String, actionId: String, inputs: Map, challengeToken: String?): EmbeddedFlowResponse { + suspend fun submit( + flowId: String, + actionId: String, + inputs: Map, + challengeToken: String?, + ): EmbeddedFlowResponse { val body = submitBody(flowId, actionId, challengeToken).toMutableMap() body["verbose"] = true if (inputs.isNotEmpty()) body["inputs"] = inputs return httpClient.post("/flow/execute", body, requiresAuth = false) } - internal fun submitBody(flowId: String, actionId: String, challengeToken: String?): Map { + internal fun submitBody( + flowId: String, + actionId: String, + challengeToken: String?, + ): Map { val body = mutableMapOf("executionId" to flowId, "action" to actionId) if (challengeToken != null) body["challengeToken"] = challengeToken return body diff --git a/src/main/kotlin/dev/thunderid/android/auth/PKCEManager.kt b/src/main/kotlin/dev/thunderid/android/auth/PKCEManager.kt index 247ce4b..6af3c08 100644 --- a/src/main/kotlin/dev/thunderid/android/auth/PKCEManager.kt +++ b/src/main/kotlin/dev/thunderid/android/auth/PKCEManager.kt @@ -1,3 +1,21 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + package dev.thunderid.android.auth import java.security.MessageDigest diff --git a/src/main/kotlin/dev/thunderid/android/http/HttpClient.kt b/src/main/kotlin/dev/thunderid/android/http/HttpClient.kt index 88a076c..0961cdc 100644 --- a/src/main/kotlin/dev/thunderid/android/http/HttpClient.kt +++ b/src/main/kotlin/dev/thunderid/android/http/HttpClient.kt @@ -1,7 +1,25 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + package dev.thunderid.android.http -import dev.thunderid.android.IAMErrorCode import dev.thunderid.android.IAMException +import dev.thunderid.android.ThunderIDErrorCode import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.json.JSONObject @@ -14,60 +32,72 @@ import java.net.URL */ internal class HttpClient( private val baseUrl: String, - private var accessTokenProvider: (suspend () -> String)? = null + private var accessTokenProvider: (suspend () -> String)? = null, ) { fun setAccessTokenProvider(provider: suspend () -> String) { accessTokenProvider = provider } - suspend inline fun get(path: String, requiresAuth: Boolean = true): T = - request("GET", path, null, requiresAuth) + suspend inline fun get( + path: String, + requiresAuth: Boolean = true, + ): T = request("GET", path, null, requiresAuth) - suspend inline fun post(path: String, body: Map, requiresAuth: Boolean = true): T = - request("POST", path, body, requiresAuth) + suspend inline fun post( + path: String, + body: Map, + requiresAuth: Boolean = true, + ): T = request("POST", path, body, requiresAuth) suspend inline fun request( method: String, path: String, body: Map?, - requiresAuth: Boolean - ): T = withContext(Dispatchers.IO) { - val urlString = baseUrl + path - if (!urlString.startsWith("https://")) { - throw IAMException(IAMErrorCode.INVALID_CONFIGURATION, "baseUrl must use HTTPS") - } - val connection = (URL(urlString).openConnection() as HttpURLConnection).apply { - requestMethod = method - setRequestProperty("Content-Type", "application/json") - setRequestProperty("Accept", "application/json") - if (requiresAuth) { - val token = accessTokenProvider?.invoke() - ?: throw IAMException(IAMErrorCode.SDK_NOT_INITIALIZED, "No access token provider") - setRequestProperty("Authorization", "Bearer $token") - } - if (body != null) { - doOutput = true - OutputStreamWriter(outputStream).use { it.write(JSONObject(body).toString()) } + requiresAuth: Boolean, + ): T = + withContext(Dispatchers.IO) { + val urlString = baseUrl + path + if (!urlString.startsWith("https://")) { + throw IAMException(ThunderIDErrorCode.INVALID_CONFIGURATION, "baseUrl must use HTTPS") } - } - val statusCode = connection.responseCode - val responseBody = runCatching { - if (statusCode in 200..299) connection.inputStream.bufferedReader().readText() - else connection.errorStream?.bufferedReader()?.readText() ?: "" - }.getOrDefault("") + val connection = + (URL(urlString).openConnection() as HttpURLConnection).apply { + requestMethod = method + setRequestProperty("Content-Type", "application/json") + setRequestProperty("Accept", "application/json") + if (requiresAuth) { + val token = + accessTokenProvider?.invoke() + ?: throw IAMException(ThunderIDErrorCode.SDK_NOT_INITIALIZED, "No access token provider") + setRequestProperty("Authorization", "Bearer $token") + } + if (body != null) { + doOutput = true + OutputStreamWriter(outputStream).use { it.write(JSONObject(body).toString()) } + } + } + val statusCode = connection.responseCode + val responseBody = + runCatching { + if (statusCode in 200..299) { + connection.inputStream.bufferedReader().readText() + } else { + connection.errorStream?.bufferedReader()?.readText() ?: "" + } + }.getOrDefault("") - when (statusCode) { - in 200..299 -> parseResponse(responseBody) - 400 -> { - val msg = runCatching { JSONObject(responseBody).optString("message", "Bad request") }.getOrDefault("Bad request") - throw IAMException(IAMErrorCode.INVALID_INPUT, msg) + when (statusCode) { + in 200..299 -> parseResponse(responseBody) + 400 -> { + val msg = runCatching { JSONObject(responseBody).optString("message", "Bad request") }.getOrDefault("Bad request") + throw IAMException(ThunderIDErrorCode.INVALID_INPUT, msg) + } + 401 -> throw IAMException(ThunderIDErrorCode.AUTHENTICATION_FAILED, "Unauthorized") + 409 -> throw IAMException(ThunderIDErrorCode.USER_ALREADY_EXISTS, "Conflict") + in 500..599 -> throw IAMException(ThunderIDErrorCode.SERVER_ERROR, "Server error: $statusCode") + else -> throw IAMException(ThunderIDErrorCode.UNKNOWN_ERROR, "Unexpected status: $statusCode") } - 401 -> throw IAMException(IAMErrorCode.AUTHENTICATION_FAILED, "Unauthorized") - 409 -> throw IAMException(IAMErrorCode.USER_ALREADY_EXISTS, "Conflict") - in 500..599 -> throw IAMException(IAMErrorCode.SERVER_ERROR, "Server error: $statusCode") - else -> throw IAMException(IAMErrorCode.UNKNOWN_ERROR, "Unexpected status: $statusCode") } - } @Suppress("UNCHECKED_CAST") private inline fun parseResponse(body: String): T { diff --git a/src/main/kotlin/dev/thunderid/android/token/JWKSCache.kt b/src/main/kotlin/dev/thunderid/android/token/JWKSCache.kt index 069c531..15c2be5 100644 --- a/src/main/kotlin/dev/thunderid/android/token/JWKSCache.kt +++ b/src/main/kotlin/dev/thunderid/android/token/JWKSCache.kt @@ -1,3 +1,21 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + package dev.thunderid.android.token import dev.thunderid.android.http.HttpClient @@ -8,7 +26,7 @@ internal data class JWK( val use: String? = null, val alg: String? = null, val n: String? = null, - val e: String? = null + val e: String? = null, ) internal data class JWKSResponse(val keys: List) diff --git a/src/main/kotlin/dev/thunderid/android/token/TokenRefresher.kt b/src/main/kotlin/dev/thunderid/android/token/TokenRefresher.kt index 9fe2fbc..707d325 100644 --- a/src/main/kotlin/dev/thunderid/android/token/TokenRefresher.kt +++ b/src/main/kotlin/dev/thunderid/android/token/TokenRefresher.kt @@ -1,7 +1,25 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + package dev.thunderid.android.token -import dev.thunderid.android.IAMErrorCode import dev.thunderid.android.IAMException +import dev.thunderid.android.ThunderIDErrorCode import dev.thunderid.android.TokenResponse import dev.thunderid.android.http.HttpClient import kotlinx.coroutines.sync.Mutex @@ -12,7 +30,7 @@ import kotlinx.coroutines.sync.withLock */ internal class TokenRefresher( private val httpClient: HttpClient, - private val tokenStore: TokenStore + private val tokenStore: TokenStore, ) { private val mutex = Mutex() @@ -27,16 +45,19 @@ internal class TokenRefresher( return refresh(clientId).accessToken } - suspend fun refresh(clientId: String): TokenResponse = mutex.withLock { - val refreshToken = tokenStore.refreshToken() - ?: throw IAMException(IAMErrorCode.SESSION_EXPIRED, "No refresh token available") - val body = mapOf( - "grant_type" to "refresh_token", - "refresh_token" to refreshToken, - "client_id" to clientId - ) - val response: TokenResponse = httpClient.post("/oauth2/token", body, requiresAuth = false) - tokenStore.save(response) - response - } + suspend fun refresh(clientId: String): TokenResponse = + mutex.withLock { + val refreshToken = + tokenStore.refreshToken() + ?: throw IAMException(ThunderIDErrorCode.SESSION_EXPIRED, "No refresh token available") + val body = + mapOf( + "grant_type" to "refresh_token", + "refresh_token" to refreshToken, + "client_id" to clientId, + ) + val response: TokenResponse = httpClient.post("/oauth2/token", body, requiresAuth = false) + tokenStore.save(response) + response + } } diff --git a/src/main/kotlin/dev/thunderid/android/token/TokenStore.kt b/src/main/kotlin/dev/thunderid/android/token/TokenStore.kt index 0806079..34471bd 100644 --- a/src/main/kotlin/dev/thunderid/android/token/TokenStore.kt +++ b/src/main/kotlin/dev/thunderid/android/token/TokenStore.kt @@ -1,3 +1,21 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + package dev.thunderid.android.token import dev.thunderid.android.StorageAdapter @@ -15,7 +33,6 @@ private object StoreKey { * Persists and retrieves the token set using the configured StorageAdapter. */ internal class TokenStore(private val storage: StorageAdapter) { - fun save(response: TokenResponse) { storage.store(StoreKey.ACCESS_TOKEN, response.accessToken) response.refreshToken?.let { storage.store(StoreKey.REFRESH_TOKEN, it) } @@ -28,7 +45,9 @@ internal class TokenStore(private val storage: StorageAdapter) { } fun accessToken(): String? = storage.retrieve(StoreKey.ACCESS_TOKEN) + fun refreshToken(): String? = storage.retrieve(StoreKey.REFRESH_TOKEN) + fun idToken(): String? = storage.retrieve(StoreKey.ID_TOKEN) fun tokenExpiry(): Long? = storage.retrieve(StoreKey.TOKEN_EXPIRY)?.toLongOrNull() diff --git a/src/main/kotlin/dev/thunderid/android/token/TokenValidator.kt b/src/main/kotlin/dev/thunderid/android/token/TokenValidator.kt index 7b51184..4d5cf0b 100644 --- a/src/main/kotlin/dev/thunderid/android/token/TokenValidator.kt +++ b/src/main/kotlin/dev/thunderid/android/token/TokenValidator.kt @@ -1,9 +1,27 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + package dev.thunderid.android.token import android.util.Base64 -import dev.thunderid.android.IAMErrorCode import dev.thunderid.android.IAMException import dev.thunderid.android.ThunderIDConfig +import dev.thunderid.android.ThunderIDErrorCode import org.json.JSONObject import java.math.BigInteger import java.security.KeyFactory @@ -15,64 +33,76 @@ import java.security.spec.RSAPublicKeySpec */ internal class TokenValidator( private val jwksCache: JWKSCache, - private val config: ThunderIDConfig + private val config: ThunderIDConfig, ) { - suspend fun validate(idToken: String, nonce: String?) { + suspend fun validate( + idToken: String, + nonce: String?, + ) { if (!config.tokenValidation.validate) return val parts = idToken.split(".") - if (parts.size != 3) throw IAMException(IAMErrorCode.AUTHENTICATION_FAILED, "Malformed ID token") + if (parts.size != 3) throw IAMException(ThunderIDErrorCode.AUTHENTICATION_FAILED, "Malformed ID token") val payload = decodePayload(parts[1]) if (config.tokenValidation.validateIssuer) { val iss = payload.optString("iss") if (iss != config.baseUrl) { - throw IAMException(IAMErrorCode.AUTHENTICATION_FAILED, "ID token iss mismatch") + throw IAMException(ThunderIDErrorCode.AUTHENTICATION_FAILED, "ID token iss mismatch") } } config.clientId?.let { clientId -> - val audValid = when { - payload.optString("aud").isNotEmpty() -> payload.optString("aud") == clientId - payload.optJSONArray("aud") != null -> { - val arr = payload.getJSONArray("aud") - (0 until arr.length()).any { arr.getString(it) == clientId } + val audValid = + when { + payload.optString("aud").isNotEmpty() -> payload.optString("aud") == clientId + payload.optJSONArray("aud") != null -> { + val arr = payload.getJSONArray("aud") + (0 until arr.length()).any { arr.getString(it) == clientId } + } + else -> false } - else -> false - } - if (!audValid) throw IAMException(IAMErrorCode.AUTHENTICATION_FAILED, "ID token aud mismatch") + if (!audValid) throw IAMException(ThunderIDErrorCode.AUTHENTICATION_FAILED, "ID token aud mismatch") } val exp = payload.optLong("exp", 0L) if (exp > 0) { val tolerance = config.tokenValidation.clockTolerance.toLong() if (System.currentTimeMillis() / 1000L > exp + tolerance) { - throw IAMException(IAMErrorCode.SESSION_EXPIRED, "ID token has expired") + throw IAMException(ThunderIDErrorCode.SESSION_EXPIRED, "ID token has expired") } } nonce?.let { val tokenNonce = payload.optString("nonce") if (tokenNonce != it) { - throw IAMException(IAMErrorCode.AUTHENTICATION_FAILED, "ID token nonce mismatch") + throw IAMException(ThunderIDErrorCode.AUTHENTICATION_FAILED, "ID token nonce mismatch") } } verifySignature(idToken, parts) } - private suspend fun verifySignature(token: String, parts: List) { + private suspend fun verifySignature( + token: String, + parts: List, + ) { var keys = jwksCache.getKeys() if (!tryVerify(parts[0], parts[1], parts[2], keys)) { keys = jwksCache.getKeys(forceRefresh = true) if (!tryVerify(parts[0], parts[1], parts[2], keys)) { - throw IAMException(IAMErrorCode.AUTHENTICATION_FAILED, "ID token signature verification failed") + throw IAMException(ThunderIDErrorCode.AUTHENTICATION_FAILED, "ID token signature verification failed") } } } - private fun tryVerify(headerB64: String, payloadB64: String, sigB64: String, keys: List): Boolean { + private fun tryVerify( + headerB64: String, + payloadB64: String, + sigB64: String, + keys: List, + ): Boolean { val header = decodePayload(headerB64) val alg = header.optString("alg") if (!alg.startsWith("RS")) return false @@ -85,7 +115,11 @@ internal class TokenValidator( return candidates.any { jwk -> verifyRSA(signingInput, sigBytes, jwk) } } - private fun verifyRSA(signingInput: ByteArray, signature: ByteArray, jwk: JWK): Boolean { + private fun verifyRSA( + signingInput: ByteArray, + signature: ByteArray, + jwk: JWK, + ): Boolean { return try { val n = BigInteger(1, Base64.decode(jwk.n?.replace('-', '+')?.replace('_', '/') ?: return false, Base64.DEFAULT)) val e = BigInteger(1, Base64.decode(jwk.e?.replace('-', '+')?.replace('_', '/') ?: return false, Base64.DEFAULT)) @@ -100,9 +134,10 @@ internal class TokenValidator( } private fun decodePayload(base64url: String): JSONObject { - val padded = base64url.replace('-', '+').replace('_', '/').let { - it + "=".repeat((4 - it.length % 4) % 4) - } + val padded = + base64url.replace('-', '+').replace('_', '/').let { + it + "=".repeat((4 - it.length % 4) % 4) + } return JSONObject(String(Base64.decode(padded, Base64.DEFAULT), Charsets.UTF_8)) } } diff --git a/src/main/kotlin/dev/thunderid/compose/LocalThunderID.kt b/src/main/kotlin/dev/thunderid/compose/LocalThunderID.kt index e15c6ff..e9b7c36 100644 --- a/src/main/kotlin/dev/thunderid/compose/LocalThunderID.kt +++ b/src/main/kotlin/dev/thunderid/compose/LocalThunderID.kt @@ -1,8 +1,27 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + package dev.thunderid.compose import androidx.compose.runtime.compositionLocalOf /** CompositionLocal for ThunderIDState — consume via [LocalThunderID.current]. */ -val LocalThunderID = compositionLocalOf { - error("No ThunderIDProvider found in the composition. Wrap your root composable with ThunderIDProvider { }.") -} +val LocalThunderID = + compositionLocalOf { + error("No ThunderIDProvider found in the composition. Wrap your root composable with ThunderIDProvider { }.") + } diff --git a/src/main/kotlin/dev/thunderid/compose/ThunderIDProvider.kt b/src/main/kotlin/dev/thunderid/compose/ThunderIDProvider.kt index 7e64663..2f7c3b0 100644 --- a/src/main/kotlin/dev/thunderid/compose/ThunderIDProvider.kt +++ b/src/main/kotlin/dev/thunderid/compose/ThunderIDProvider.kt @@ -1,10 +1,31 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + package dev.thunderid.compose -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import dev.thunderid.android.ThunderIDClient import dev.thunderid.android.ThunderIDConfig import dev.thunderid.compose.i18n.ThunderIDI18n -import kotlinx.coroutines.launch /** * Provides ThunderID auth state to all descendant composables via [LocalThunderID] (spec §7.2). diff --git a/src/main/kotlin/dev/thunderid/compose/ThunderIDState.kt b/src/main/kotlin/dev/thunderid/compose/ThunderIDState.kt index 23777f4..186ed09 100644 --- a/src/main/kotlin/dev/thunderid/compose/ThunderIDState.kt +++ b/src/main/kotlin/dev/thunderid/compose/ThunderIDState.kt @@ -1,6 +1,27 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + package dev.thunderid.compose -import androidx.compose.runtime.* +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import dev.thunderid.android.ThunderIDClient import dev.thunderid.android.ThunderIDConfig import dev.thunderid.android.User diff --git a/src/main/kotlin/dev/thunderid/compose/components/actions/SignInButton.kt b/src/main/kotlin/dev/thunderid/compose/components/actions/SignInButton.kt index dae998f..dd97938 100644 --- a/src/main/kotlin/dev/thunderid/compose/components/actions/SignInButton.kt +++ b/src/main/kotlin/dev/thunderid/compose/components/actions/SignInButton.kt @@ -1,3 +1,21 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + package dev.thunderid.compose.components.actions import androidx.compose.foundation.clickable @@ -14,7 +32,10 @@ import dev.thunderid.compose.LocalThunderID /** Tappable button that starts the redirect-based sign-in flow (spec §8.4 Actions). */ @Composable -fun SignInButton(modifier: Modifier = Modifier, onTap: (() -> Unit)? = null) { +fun SignInButton( + modifier: Modifier = Modifier, + onTap: (() -> Unit)? = null, +) { val state = LocalThunderID.current val label = state.i18n.resolve("signIn.button") BaseSignInButton(label = label, isLoading = state.isLoading, modifier = modifier) { @@ -32,10 +53,11 @@ fun BaseSignInButton( ) { Box( contentAlignment = Alignment.Center, - modifier = modifier - .defaultMinSize(minWidth = 44.dp, minHeight = 44.dp) - .semantics { contentDescription = label } - .then(if (!isLoading) Modifier.clickable(onClick = onClick) else Modifier), + modifier = + modifier + .defaultMinSize(minWidth = 44.dp, minHeight = 44.dp) + .semantics { contentDescription = label } + .then(if (!isLoading) Modifier.clickable(onClick = onClick) else Modifier), ) { BasicText(label) } diff --git a/src/main/kotlin/dev/thunderid/compose/components/actions/SignOutButton.kt b/src/main/kotlin/dev/thunderid/compose/components/actions/SignOutButton.kt index 983f6d3..1f0018e 100644 --- a/src/main/kotlin/dev/thunderid/compose/components/actions/SignOutButton.kt +++ b/src/main/kotlin/dev/thunderid/compose/components/actions/SignOutButton.kt @@ -1,3 +1,21 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + package dev.thunderid.compose.components.actions import androidx.compose.foundation.clickable @@ -16,7 +34,10 @@ import kotlinx.coroutines.launch /** Button that calls signOut and refreshes auth state (spec §8.4 Actions). */ @Composable -fun SignOutButton(modifier: Modifier = Modifier, onSignOutComplete: (() -> Unit)? = null) { +fun SignOutButton( + modifier: Modifier = Modifier, + onSignOutComplete: (() -> Unit)? = null, +) { val state = LocalThunderID.current val scope = rememberCoroutineScope() val label = state.i18n.resolve("signOut.button") @@ -39,10 +60,11 @@ fun BaseSignOutButton( ) { Box( contentAlignment = Alignment.Center, - modifier = modifier - .defaultMinSize(minWidth = 44.dp, minHeight = 44.dp) - .semantics { contentDescription = label } - .then(if (!isLoading) Modifier.clickable(onClick = onClick) else Modifier), + modifier = + modifier + .defaultMinSize(minWidth = 44.dp, minHeight = 44.dp) + .semantics { contentDescription = label } + .then(if (!isLoading) Modifier.clickable(onClick = onClick) else Modifier), ) { BasicText(label) } diff --git a/src/main/kotlin/dev/thunderid/compose/components/actions/SignUpButton.kt b/src/main/kotlin/dev/thunderid/compose/components/actions/SignUpButton.kt index 7dfc96e..d6c1535 100644 --- a/src/main/kotlin/dev/thunderid/compose/components/actions/SignUpButton.kt +++ b/src/main/kotlin/dev/thunderid/compose/components/actions/SignUpButton.kt @@ -1,3 +1,21 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + package dev.thunderid.compose.components.actions import androidx.compose.foundation.clickable @@ -14,7 +32,10 @@ import dev.thunderid.compose.LocalThunderID /** Button that initiates the sign-up flow (spec §8.4 Actions). */ @Composable -fun SignUpButton(modifier: Modifier = Modifier, onTap: (() -> Unit)? = null) { +fun SignUpButton( + modifier: Modifier = Modifier, + onTap: (() -> Unit)? = null, +) { val state = LocalThunderID.current val label = state.i18n.resolve("signUp.button") BaseSignUpButton(label = label, modifier = modifier) { onTap?.invoke() } @@ -29,10 +50,11 @@ fun BaseSignUpButton( ) { Box( contentAlignment = Alignment.Center, - modifier = modifier - .defaultMinSize(minWidth = 44.dp, minHeight = 44.dp) - .semantics { contentDescription = label } - .clickable(onClick = onClick), + modifier = + modifier + .defaultMinSize(minWidth = 44.dp, minHeight = 44.dp) + .semantics { contentDescription = label } + .clickable(onClick = onClick), ) { BasicText(label) } diff --git a/src/main/kotlin/dev/thunderid/compose/components/flow/Callback.kt b/src/main/kotlin/dev/thunderid/compose/components/flow/Callback.kt index d67abf8..9a671a9 100644 --- a/src/main/kotlin/dev/thunderid/compose/components/flow/Callback.kt +++ b/src/main/kotlin/dev/thunderid/compose/components/flow/Callback.kt @@ -1,7 +1,30 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + package dev.thunderid.compose.components.flow import androidx.compose.foundation.text.BasicText -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import dev.thunderid.compose.LocalThunderID diff --git a/src/main/kotlin/dev/thunderid/compose/components/guards/Loading.kt b/src/main/kotlin/dev/thunderid/compose/components/guards/Loading.kt index 5d7b062..3bbe31c 100644 --- a/src/main/kotlin/dev/thunderid/compose/components/guards/Loading.kt +++ b/src/main/kotlin/dev/thunderid/compose/components/guards/Loading.kt @@ -1,3 +1,21 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + package dev.thunderid.compose.components.guards import androidx.compose.runtime.Composable @@ -5,9 +23,7 @@ import dev.thunderid.compose.LocalThunderID /** Renders [indicator] while the SDK is initializing or mid-operation (spec §8.4 Guards). */ @Composable -fun Loading( - indicator: @Composable () -> Unit = {}, -) { +fun Loading(indicator: @Composable () -> Unit = {}) { val state = LocalThunderID.current if (state.isLoading) indicator() } diff --git a/src/main/kotlin/dev/thunderid/compose/components/guards/SignedIn.kt b/src/main/kotlin/dev/thunderid/compose/components/guards/SignedIn.kt index b443fbd..798438a 100644 --- a/src/main/kotlin/dev/thunderid/compose/components/guards/SignedIn.kt +++ b/src/main/kotlin/dev/thunderid/compose/components/guards/SignedIn.kt @@ -1,3 +1,21 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + package dev.thunderid.compose.components.guards import androidx.compose.runtime.Composable diff --git a/src/main/kotlin/dev/thunderid/compose/components/guards/SignedOut.kt b/src/main/kotlin/dev/thunderid/compose/components/guards/SignedOut.kt index 82f552c..ffb92ae 100644 --- a/src/main/kotlin/dev/thunderid/compose/components/guards/SignedOut.kt +++ b/src/main/kotlin/dev/thunderid/compose/components/guards/SignedOut.kt @@ -1,3 +1,21 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + package dev.thunderid.compose.components.guards import androidx.compose.runtime.Composable diff --git a/src/main/kotlin/dev/thunderid/compose/components/presentation/auth/SignIn.kt b/src/main/kotlin/dev/thunderid/compose/components/presentation/auth/SignIn.kt index 26e7f9a..e66309f 100644 --- a/src/main/kotlin/dev/thunderid/compose/components/presentation/auth/SignIn.kt +++ b/src/main/kotlin/dev/thunderid/compose/components/presentation/auth/SignIn.kt @@ -1,14 +1,51 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + package dev.thunderid.compose.components.presentation.auth -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.BasicText import androidx.compose.foundation.text.BasicTextField -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp -import dev.thunderid.android.* +import dev.thunderid.android.EmbeddedFlowRequestConfig +import dev.thunderid.android.EmbeddedFlowResponse +import dev.thunderid.android.EmbeddedSignInPayload +import dev.thunderid.android.FlowAction +import dev.thunderid.android.FlowInput +import dev.thunderid.android.FlowStatus +import dev.thunderid.android.FlowType import dev.thunderid.compose.LocalThunderID import dev.thunderid.compose.ThunderIDState import dev.thunderid.compose.components.actions.BaseSignInButton @@ -32,8 +69,16 @@ class SignInState { private val fieldValues = mutableStateMapOf() fun fieldValue(name: String): String = fieldValues[name] ?: "" - fun setField(name: String, value: String) { fieldValues[name] = value } + + fun setField( + name: String, + value: String, + ) { + fieldValues[name] = value + } + fun fields(): Map = fieldValues.toMap() + fun submit(actionId: String) = onSubmit(actionId) internal fun update(response: EmbeddedFlowResponse) { @@ -62,8 +107,9 @@ fun SignIn( BasicTextField( value = signInState.fieldValue(input.name), onValueChange = { signInState.setField(input.name, it) }, - modifier = Modifier.fillMaxWidth().defaultMinSize(minHeight = 44.dp) - .semantics { contentDescription = input.name }, + modifier = + Modifier.fillMaxWidth().defaultMinSize(minHeight = 44.dp) + .semantics { contentDescription = input.name }, ) } signInState.actions.forEach { action -> @@ -95,10 +141,11 @@ fun BaseSignIn( signInState.isLoading = true signInState.error = null try { - val payload = EmbeddedSignInPayload( - flowId = signInState.flowId, actionId = actionId, inputs = signInState.fields(), - challengeToken = signInState.challengeToken - ) + val payload = + EmbeddedSignInPayload( + flowId = signInState.flowId, actionId = actionId, inputs = signInState.fields(), + challengeToken = signInState.challengeToken, + ) val request = EmbeddedFlowRequestConfig(applicationId, FlowType.AUTHENTICATION) val response = thunderState.client.signIn(payload = payload, request = request) handleSignInResponse(response, signInState, thunderState, onComplete, onError) @@ -137,8 +184,12 @@ private suspend fun handleSignInResponse( onError: ((String) -> Unit)?, ) { when (response.flowStatus) { - FlowStatus.COMPLETE -> { thunderState.refresh(); onComplete?.invoke() } + FlowStatus.COMPLETE -> { + thunderState.refresh() + onComplete?.invoke() + } FlowStatus.PROMPT_ONLY -> signInState.update(response) + FlowStatus.INCOMPLETE -> {} FlowStatus.ERROR -> { val msg = response.failureReason ?: "Sign-in failed" signInState.error = msg diff --git a/src/main/kotlin/dev/thunderid/compose/components/presentation/auth/SignUp.kt b/src/main/kotlin/dev/thunderid/compose/components/presentation/auth/SignUp.kt index d176094..e0c4e58 100644 --- a/src/main/kotlin/dev/thunderid/compose/components/presentation/auth/SignUp.kt +++ b/src/main/kotlin/dev/thunderid/compose/components/presentation/auth/SignUp.kt @@ -1,14 +1,49 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + package dev.thunderid.compose.components.presentation.auth -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.BasicText import androidx.compose.foundation.text.BasicTextField -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp -import dev.thunderid.android.* +import dev.thunderid.android.EmbeddedFlowResponse +import dev.thunderid.android.EmbeddedSignInPayload +import dev.thunderid.android.FlowAction +import dev.thunderid.android.FlowInput +import dev.thunderid.android.FlowStatus import dev.thunderid.compose.LocalThunderID import dev.thunderid.compose.ThunderIDState import dev.thunderid.compose.components.actions.BaseSignUpButton @@ -32,8 +67,16 @@ class SignUpState { private val fieldValues = mutableStateMapOf() fun fieldValue(name: String): String = fieldValues[name] ?: "" - fun setField(name: String, value: String) { fieldValues[name] = value } + + fun setField( + name: String, + value: String, + ) { + fieldValues[name] = value + } + fun fields(): Map = fieldValues.toMap() + fun submit(actionId: String) = onSubmit(actionId) internal fun update(response: EmbeddedFlowResponse) { @@ -61,8 +104,9 @@ fun SignUp( BasicTextField( value = state.fieldValue(input.name), onValueChange = { state.setField(input.name, it) }, - modifier = Modifier.fillMaxWidth().defaultMinSize(minHeight = 44.dp) - .semantics { contentDescription = input.name }, + modifier = + Modifier.fillMaxWidth().defaultMinSize(minHeight = 44.dp) + .semantics { contentDescription = input.name }, ) } state.actions.forEach { action -> @@ -89,15 +133,24 @@ fun BaseSignUp( signUpState.onSubmit = { actionId -> scope.launch { - signUpState.isLoading = true; signUpState.error = null + signUpState.isLoading = true + signUpState.error = null try { - val payload = EmbeddedSignInPayload(flowId = signUpState.flowId, actionId = actionId, inputs = signUpState.fields(), challengeToken = signUpState.challengeToken) + val payload = + EmbeddedSignInPayload( + flowId = signUpState.flowId, + actionId = actionId, + inputs = signUpState.fields(), + challengeToken = signUpState.challengeToken, + ) val response = thunderState.client.signUp(payload = payload) handleSignUpResponse(response, signUpState, thunderState, onComplete, onError) } catch (e: Exception) { signUpState.error = e.message onError?.invoke(e.message ?: "Sign-up failed") - } finally { signUpState.isLoading = false } + } finally { + signUpState.isLoading = false + } } } @@ -109,7 +162,9 @@ fun BaseSignUp( } catch (e: Exception) { signUpState.error = e.message onError?.invoke(e.message ?: "Sign-up failed") - } finally { signUpState.isLoading = false } + } finally { + signUpState.isLoading = false + } } Box(modifier = modifier) { content(signUpState) } @@ -123,8 +178,12 @@ private suspend fun handleSignUpResponse( onError: ((String) -> Unit)?, ) { when (response.flowStatus) { - FlowStatus.COMPLETE -> { thunderState.refresh(); onComplete?.invoke() } + FlowStatus.COMPLETE -> { + thunderState.refresh() + onComplete?.invoke() + } FlowStatus.PROMPT_ONLY -> state.update(response) + FlowStatus.INCOMPLETE -> {} FlowStatus.ERROR -> { val msg = response.failureReason ?: "Sign-up failed" state.error = msg diff --git a/src/main/kotlin/dev/thunderid/compose/components/presentation/organization/LanguageSwitcher.kt b/src/main/kotlin/dev/thunderid/compose/components/presentation/organization/LanguageSwitcher.kt index 64e9f4a..069e110 100644 --- a/src/main/kotlin/dev/thunderid/compose/components/presentation/organization/LanguageSwitcher.kt +++ b/src/main/kotlin/dev/thunderid/compose/components/presentation/organization/LanguageSwitcher.kt @@ -1,12 +1,36 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + package dev.thunderid.compose.components.presentation.organization import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.text.BasicText -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.semantics.* +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import dev.thunderid.compose.LocalThunderID @@ -22,13 +46,14 @@ fun LanguageSwitcher( available.forEach { locale -> BasicText( locale, - modifier = Modifier - .defaultMinSize(minHeight = 44.dp) - .clickable { select(locale) } - .semantics { - contentDescription = locale - selected = locale == active - }, + modifier = + Modifier + .defaultMinSize(minHeight = 44.dp) + .clickable { select(locale) } + .semantics { + contentDescription = locale + selected = locale == active + }, ) } } diff --git a/src/main/kotlin/dev/thunderid/compose/components/presentation/user/User.kt b/src/main/kotlin/dev/thunderid/compose/components/presentation/user/User.kt index 253a3b0..b598f6a 100644 --- a/src/main/kotlin/dev/thunderid/compose/components/presentation/user/User.kt +++ b/src/main/kotlin/dev/thunderid/compose/components/presentation/user/User.kt @@ -1,3 +1,21 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + package dev.thunderid.compose.components.presentation.user import androidx.compose.foundation.text.BasicText diff --git a/src/main/kotlin/dev/thunderid/compose/components/presentation/user/UserDropdown.kt b/src/main/kotlin/dev/thunderid/compose/components/presentation/user/UserDropdown.kt index b7acf4c..eed52bc 100644 --- a/src/main/kotlin/dev/thunderid/compose/components/presentation/user/UserDropdown.kt +++ b/src/main/kotlin/dev/thunderid/compose/components/presentation/user/UserDropdown.kt @@ -1,9 +1,35 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + package dev.thunderid.compose.components.presentation.user import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.size import androidx.compose.foundation.text.BasicText -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.semantics.contentDescription @@ -26,9 +52,10 @@ fun UserDropdown( Column(horizontalAlignment = Alignment.End) { Box( contentAlignment = Alignment.Center, - modifier = Modifier.size(44.dp) - .clickable { toggle() } - .semantics { contentDescription = user?.displayName ?: i18n.resolve("user.anonymous") }, + modifier = + Modifier.size(44.dp) + .clickable { toggle() } + .semantics { contentDescription = user?.displayName ?: i18n.resolve("user.anonymous") }, ) { BasicText(initials(user)) } @@ -42,10 +69,11 @@ fun UserDropdown( } BasicText( i18n.resolve("signOut.button"), - modifier = Modifier.defaultMinSize(minHeight = 44.dp).clickable { - signOut() - onSignOutComplete?.invoke() - }, + modifier = + Modifier.defaultMinSize(minHeight = 44.dp).clickable { + signOut() + onSignOutComplete?.invoke() + }, ) } } @@ -81,6 +109,9 @@ fun BaseUserDropdown( private fun initials(user: User?): String { val name = user?.displayName ?: user?.username ?: user?.email ?: "?" val parts = name.trim().split(" ") - return if (parts.size >= 2) "${parts.first().first()}${parts.last().first()}".uppercase() - else name.take(1).uppercase() + return if (parts.size >= 2) { + "${parts.first().first()}${parts.last().first()}".uppercase() + } else { + name.take(1).uppercase() + } } diff --git a/src/main/kotlin/dev/thunderid/compose/components/presentation/user/UserProfile.kt b/src/main/kotlin/dev/thunderid/compose/components/presentation/user/UserProfile.kt index c3a62a5..5f30957 100644 --- a/src/main/kotlin/dev/thunderid/compose/components/presentation/user/UserProfile.kt +++ b/src/main/kotlin/dev/thunderid/compose/components/presentation/user/UserProfile.kt @@ -1,9 +1,39 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + package dev.thunderid.compose.components.presentation.user -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.BasicText import androidx.compose.foundation.text.BasicTextField -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics @@ -35,8 +65,9 @@ fun UserProfile( BasicTextField( value = fields[key]?.value ?: "", onValueChange = { fields[key]?.value = it }, - modifier = Modifier.fillMaxWidth().defaultMinSize(minHeight = 44.dp) - .semantics { contentDescription = key }, + modifier = + Modifier.fillMaxWidth().defaultMinSize(minHeight = 44.dp) + .semantics { contentDescription = key }, ) } BaseSignInButton( @@ -65,18 +96,22 @@ fun BaseUserProfile( var error by remember { mutableStateOf(null) } LaunchedEffect(Unit) { - isLoading = true; error = null + isLoading = true + error = null try { val p = thunderState.client.getUserProfile() editableKeys.forEach { key -> fields[key]?.value = p.claims[key]?.toString() ?: "" } profile = p - } catch (e: Exception) { error = e.message } + } catch (e: Exception) { + error = e.message + } isLoading = false } val save = { scope.launch { - isLoading = true; error = null + isLoading = true + error = null try { thunderState.client.updateUserProfile(fields.mapValues { it.value.value }) isLoading = false diff --git a/src/main/kotlin/dev/thunderid/compose/i18n/DefaultStrings.kt b/src/main/kotlin/dev/thunderid/compose/i18n/DefaultStrings.kt index 8bc457e..cda7639 100644 --- a/src/main/kotlin/dev/thunderid/compose/i18n/DefaultStrings.kt +++ b/src/main/kotlin/dev/thunderid/compose/i18n/DefaultStrings.kt @@ -1,39 +1,58 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + package dev.thunderid.compose.i18n /** English default strings for all ThunderIDCompose components. */ object DefaultStrings { - val all: Map = mapOf( - "signIn.button" to "Sign in", - "signIn.title" to "Sign in", - "signIn.submit" to "Continue", - "signIn.loading" to "Signing in…", - "signIn.error" to "Sign-in failed", - "signUp.button" to "Sign up", - "signUp.title" to "Create account", - "signUp.submit" to "Create account", - "signUp.loading" to "Creating account…", - "signOut.button" to "Sign out", - "signOut.loading" to "Signing out…", - "callback.loading" to "Completing sign-in…", - "callback.error" to "Could not complete sign-in", - "user.anonymous" to "Anonymous", - "userProfile.title" to "Profile", - "userProfile.save" to "Save", - "userProfile.loading" to "Loading profile…", - "userProfile.saving" to "Saving…", - "organization.unnamed" to "Unnamed organization", - "organizationList.empty" to "No organizations", - "organizationSwitcher.empty" to "No organizations", - "createOrganization.title" to "New organization", - "createOrganization.name" to "Name", - "createOrganization.handle" to "Handle (optional)", - "createOrganization.submit" to "Create", - "languageSwitcher.title" to "Language", - "acceptInvite.title" to "Accept invitation", - "acceptInvite.submit" to "Accept", - "inviteUser.title" to "Invite user", - "inviteUser.email" to "Email", - "inviteUser.submit" to "Send invite", - "inviteUser.loading" to "Sending…", - ) + val all: Map = + mapOf( + "signIn.button" to "Sign in", + "signIn.title" to "Sign in", + "signIn.submit" to "Continue", + "signIn.loading" to "Signing in…", + "signIn.error" to "Sign-in failed", + "signUp.button" to "Sign up", + "signUp.title" to "Create account", + "signUp.submit" to "Create account", + "signUp.loading" to "Creating account…", + "signOut.button" to "Sign out", + "signOut.loading" to "Signing out…", + "callback.loading" to "Completing sign-in…", + "callback.error" to "Could not complete sign-in", + "user.anonymous" to "Anonymous", + "userProfile.title" to "Profile", + "userProfile.save" to "Save", + "userProfile.loading" to "Loading profile…", + "userProfile.saving" to "Saving…", + "organization.unnamed" to "Unnamed organization", + "organizationList.empty" to "No organizations", + "organizationSwitcher.empty" to "No organizations", + "createOrganization.title" to "New organization", + "createOrganization.name" to "Name", + "createOrganization.handle" to "Handle (optional)", + "createOrganization.submit" to "Create", + "languageSwitcher.title" to "Language", + "acceptInvite.title" to "Accept invitation", + "acceptInvite.submit" to "Accept", + "inviteUser.title" to "Invite user", + "inviteUser.email" to "Email", + "inviteUser.submit" to "Send invite", + "inviteUser.loading" to "Sending…", + ) } diff --git a/src/main/kotlin/dev/thunderid/compose/i18n/ThunderIDI18n.kt b/src/main/kotlin/dev/thunderid/compose/i18n/ThunderIDI18n.kt index 1274b4a..f91deb6 100644 --- a/src/main/kotlin/dev/thunderid/compose/i18n/ThunderIDI18n.kt +++ b/src/main/kotlin/dev/thunderid/compose/i18n/ThunderIDI18n.kt @@ -1,3 +1,21 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + package dev.thunderid.compose.i18n import android.content.Context @@ -14,9 +32,10 @@ class ThunderIDI18n( private val prefs: SharedPreferences? = context?.getSharedPreferences("thunder_i18n", Context.MODE_PRIVATE) - var activeLocale: String = language - ?: prefs?.getString(storageKey, null) - ?: fallbackLanguage + var activeLocale: String = + language + ?: prefs?.getString(storageKey, null) + ?: fallbackLanguage private set /** Returns the localized string for [key], falling back through the resolution chain. */ diff --git a/src/test/kotlin/dev/thunderid/android/ThunderIDClientTest.kt b/src/test/kotlin/dev/thunderid/android/ThunderIDClientTest.kt index 2cdfd4a..0232b54 100644 --- a/src/test/kotlin/dev/thunderid/android/ThunderIDClientTest.kt +++ b/src/test/kotlin/dev/thunderid/android/ThunderIDClientTest.kt @@ -1,9 +1,33 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + package dev.thunderid.android import dev.thunderid.android.auth.FlowExecutionClient import dev.thunderid.android.http.HttpClient import kotlinx.coroutines.test.runTest -import org.junit.Assert.* +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail import org.junit.Before import org.junit.Test @@ -20,47 +44,53 @@ class ThunderIDClientTest { // Initialization @Test - fun `initialize succeeds with valid https config`() = runTest { - val config = ThunderIDConfig(baseUrl = "https://localhost:8090", clientId = "test") - assertTrue(client.initialize(config, storage)) - } + fun `initialize succeeds with valid https config`() = + runTest { + val config = ThunderIDConfig(baseUrl = "https://localhost:8090", clientId = "test") + assertTrue(client.initialize(config, storage)) + } @Test(expected = IAMException::class) - fun `initialize rejects http baseUrl`() = runTest { - val config = ThunderIDConfig(baseUrl = "http://localhost:8090", clientId = "test") - client.initialize(config, storage) - } + fun `initialize rejects http baseUrl`() = + runTest { + val config = ThunderIDConfig(baseUrl = "http://localhost:8090", clientId = "test") + client.initialize(config, storage) + } @Test(expected = IAMException::class) - fun `initialize throws when called twice`() = runTest { - val config = ThunderIDConfig(baseUrl = "https://localhost:8090", clientId = "test") - client.initialize(config, storage) - client.initialize(config, storage) // should throw ALREADY_INITIALIZED - } + fun `initialize throws when called twice`() = + runTest { + val config = ThunderIDConfig(baseUrl = "https://localhost:8090", clientId = "test") + client.initialize(config, storage) + client.initialize(config, storage) // should throw ALREADY_INITIALIZED + } @Test - fun `operations before init throw SDK_NOT_INITIALIZED`() = runTest { - try { - client.isSignedIn() - fail("Expected IAMException") - } catch (e: IAMException) { - assertEquals(IAMErrorCode.SDK_NOT_INITIALIZED, e.code) + fun `operations before init throw SDK_NOT_INITIALIZED`() = + runTest { + try { + client.isSignedIn() + fail("Expected IAMException") + } catch (e: IAMException) { + assertEquals(ThunderIDErrorCode.SDK_NOT_INITIALIZED, e.code) + } } - } @Test - fun `getConfiguration returns config after init`() = runTest { - val config = ThunderIDConfig( - baseUrl = "https://localhost:8090", - clientId = "my-client", - scopes = listOf("openid", "profile") - ) - client.initialize(config, storage) - val retrieved = client.getConfiguration() - assertEquals("https://localhost:8090", retrieved.baseUrl) - assertEquals("my-client", retrieved.clientId) - assertEquals(listOf("openid", "profile"), retrieved.scopes) - } + fun `getConfiguration returns config after init`() = + runTest { + val config = + ThunderIDConfig( + baseUrl = "https://localhost:8090", + clientId = "my-client", + scopes = listOf("openid", "profile"), + ) + client.initialize(config, storage) + val retrieved = client.getConfiguration() + assertEquals("https://localhost:8090", retrieved.baseUrl) + assertEquals("my-client", retrieved.clientId) + assertEquals(listOf("openid", "profile"), retrieved.scopes) + } // PKCE @@ -91,13 +121,14 @@ class ThunderIDClientTest { @Test fun `TokenStore saves and retrieves tokens`() { val store = dev.thunderid.android.token.TokenStore(storage) - val response = TokenResponse( - accessToken = "access123", - tokenType = "Bearer", - expiresIn = 3600, - refreshToken = "refresh456", - idToken = "id789" - ) + val response = + TokenResponse( + accessToken = "access123", + tokenType = "Bearer", + expiresIn = 3600, + refreshToken = "refresh456", + idToken = "id789", + ) store.save(response) assertEquals("access123", store.accessToken()) assertEquals("refresh456", store.refreshToken()) @@ -129,36 +160,38 @@ class ThunderIDClientTest { // isLoading @Test - fun `isLoading defaults false after init`() = runTest { - client.initialize(ThunderIDConfig(baseUrl = "https://localhost:8090", clientId = "test"), storage) - assertFalse(client.isLoading()) - } + fun `isLoading defaults false after init`() = + runTest { + client.initialize(ThunderIDConfig(baseUrl = "https://localhost:8090", clientId = "test"), storage) + assertFalse(client.isLoading()) + } // clearSession @Test - fun `clearSession means isSignedIn returns false`() = runTest { - client.initialize(ThunderIDConfig(baseUrl = "https://localhost:8090", clientId = "test"), storage) - client.clearSession() - assertFalse(client.isSignedIn()) - } + fun `clearSession means isSignedIn returns false`() = + runTest { + client.initialize(ThunderIDConfig(baseUrl = "https://localhost:8090", clientId = "test"), storage) + client.clearSession() + assertFalse(client.isSignedIn()) + } // Error codes @Test - fun `IAMErrorCode fromValue round-trips`() { - val code = IAMErrorCode.fromValue("AUTHENTICATION_FAILED") - assertEquals(IAMErrorCode.AUTHENTICATION_FAILED, code) + fun `ThunderIDErrorCode fromValue round-trips`() { + val code = ThunderIDErrorCode.fromValue("AUTHENTICATION_FAILED") + assertEquals(ThunderIDErrorCode.AUTHENTICATION_FAILED, code) } @Test - fun `IAMErrorCode fromValue returns UNKNOWN_ERROR for unknown`() { - assertEquals(IAMErrorCode.UNKNOWN_ERROR, IAMErrorCode.fromValue("NOT_A_REAL_CODE")) + fun `ThunderIDErrorCode fromValue returns UNKNOWN_ERROR for unknown`() { + assertEquals(ThunderIDErrorCode.UNKNOWN_ERROR, ThunderIDErrorCode.fromValue("NOT_A_REAL_CODE")) } @Test fun `IAMException message includes code`() { - val ex = IAMException(IAMErrorCode.NETWORK_ERROR, "connection refused") + val ex = IAMException(ThunderIDErrorCode.NETWORK_ERROR, "connection refused") assertTrue(ex.message!!.contains("NETWORK_ERROR")) } @@ -166,7 +199,7 @@ class ThunderIDClientTest { fun `flow submit body uses action field`() { val flowClient = FlowExecutionClient(HttpClient(baseUrl = "https://localhost:8090")) - val body = flowClient.submitBody("flow-123", "basic_auth") + val body = flowClient.submitBody("flow-123", "basic_auth", null) assertEquals("flow-123", body["executionId"]) assertEquals("basic_auth", body["action"]) diff --git a/src/test/kotlin/dev/thunderid/compose/ComponentTests.kt b/src/test/kotlin/dev/thunderid/compose/ComponentTests.kt index 88fec4e..a006430 100644 --- a/src/test/kotlin/dev/thunderid/compose/ComponentTests.kt +++ b/src/test/kotlin/dev/thunderid/compose/ComponentTests.kt @@ -1,12 +1,30 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + package dev.thunderid.compose import dev.thunderid.compose.i18n.DefaultStrings import dev.thunderid.compose.i18n.ThunderIDI18n -import org.junit.Assert.* +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull import org.junit.Test class ComponentTests { - // ── ThunderIDI18n ────────────────────────────────────────────────────────── @Test @@ -29,10 +47,11 @@ class ComponentTests { @Test fun `i18n sets locale`() { - val i18n = ThunderIDI18n( - bundles = mapOf("fr-FR" to mapOf("signIn.button" to "Se connecter")), - language = "en-US", - ) + val i18n = + ThunderIDI18n( + bundles = mapOf("fr-FR" to mapOf("signIn.button" to "Se connecter")), + language = "en-US", + ) i18n.setLocale("fr-FR") assertEquals("fr-FR", i18n.activeLocale) assertEquals("Se connecter", i18n.resolve("signIn.button")) @@ -40,11 +59,12 @@ class ComponentTests { @Test fun `i18n falls back through fallback locale`() { - val i18n = ThunderIDI18n( - bundles = mapOf("es-ES" to mapOf("signIn.button" to "Iniciar sesión")), - language = "de-DE", - fallbackLanguage = "es-ES", - ) + val i18n = + ThunderIDI18n( + bundles = mapOf("es-ES" to mapOf("signIn.button" to "Iniciar sesión")), + language = "de-DE", + fallbackLanguage = "es-ES", + ) assertEquals("Iniciar sesión", i18n.resolve("signIn.button")) } @@ -58,12 +78,13 @@ class ComponentTests { @Test fun `DefaultStrings contains all expected keys`() { - val required = listOf( - "signIn.button", "signOut.button", "signUp.button", - "userProfile.title", "userProfile.save", - "organizationList.empty", "createOrganization.submit", - "languageSwitcher.title", "acceptInvite.submit", "inviteUser.submit", - ) + val required = + listOf( + "signIn.button", "signOut.button", "signUp.button", + "userProfile.title", "userProfile.save", + "organizationList.empty", "createOrganization.submit", + "languageSwitcher.title", "acceptInvite.submit", "inviteUser.submit", + ) required.forEach { key -> assertNotNull("Missing default string for key: $key", DefaultStrings.all[key]) }