├── demo-android
├── .gitignore
├── src
│ └── main
│ │ ├── ic_launcher-playstore.png
│ │ ├── res
│ │ ├── mipmap-hdpi
│ │ │ ├── ic_launcher.png
│ │ │ └── ic_launcher_round.png
│ │ ├── mipmap-mdpi
│ │ │ ├── ic_launcher.png
│ │ │ └── ic_launcher_round.png
│ │ ├── mipmap-xhdpi
│ │ │ ├── ic_launcher.png
│ │ │ └── ic_launcher_round.png
│ │ ├── mipmap-xxhdpi
│ │ │ ├── ic_launcher.png
│ │ │ └── ic_launcher_round.png
│ │ ├── mipmap-xxxhdpi
│ │ │ ├── ic_launcher.png
│ │ │ └── ic_launcher_round.png
│ │ ├── values
│ │ │ ├── ic_launcher_background.xml
│ │ │ ├── colors.xml
│ │ │ ├── dimens.xml
│ │ │ ├── styles.xml
│ │ │ └── strings.xml
│ │ ├── layout
│ │ │ ├── activity_login.xml
│ │ │ ├── activity_main.xml
│ │ │ ├── listitem_repository.xml
│ │ │ └── activity_repository_list.xml
│ │ ├── mipmap-anydpi-v26
│ │ │ ├── ic_launcher.xml
│ │ │ └── ic_launcher_round.xml
│ │ ├── values-w820dp
│ │ │ └── dimens.xml
│ │ ├── xml
│ │ │ └── authenticator.xml
│ │ ├── drawable
│ │ │ ├── ic_baseline_lock_24.xml
│ │ │ ├── ic_baseline_lock_open_24.xml
│ │ │ └── ic_launcher_foreground.xml
│ │ └── menu
│ │ │ └── menu.xml
│ │ ├── java
│ │ └── com
│ │ │ └── andretietz
│ │ │ └── retroauth
│ │ │ └── demo
│ │ │ ├── RetroauthDemoApplication.kt
│ │ │ ├── screen
│ │ │ └── main
│ │ │ │ ├── MainViewState.kt
│ │ │ │ ├── SwitchAccountContract.kt
│ │ │ │ ├── RepositoryAdapter.kt
│ │ │ │ ├── MainViewModel.kt
│ │ │ │ └── MainActivity.kt
│ │ │ ├── api
│ │ │ └── GithubApi.kt
│ │ │ ├── auth
│ │ │ ├── DemoAuthenticationService.kt
│ │ │ ├── GithubAuthenticator.kt
│ │ │ └── LoginActivity.kt
│ │ │ └── di
│ │ │ └── ApiModule.kt
│ │ └── AndroidManifest.xml
└── build.gradle.kts
├── android-accountmanager
├── proguard-rules.pro
├── src
│ ├── test
│ │ ├── resources
│ │ │ └── mockito-extensions
│ │ │ │ └── org.mockito.plugins.MockMaker
│ │ └── java
│ │ │ └── com
│ │ │ └── andretietz
│ │ │ └── retroauth
│ │ │ ├── ActivityManagerTest.kt
│ │ │ ├── WeakActivityStackTest.kt
│ │ │ └── AccountAuthenticatorTest.kt
│ └── main
│ │ ├── java
│ │ └── com
│ │ │ └── andretietz
│ │ │ └── retroauth
│ │ │ ├── RetroauthInitProvider.kt
│ │ │ ├── RetroauthAndroid.kt
│ │ │ ├── AuthenticationService.kt
│ │ │ ├── WeakActivityStack.kt
│ │ │ ├── AndroidAccountManagerOwnerStorage.kt
│ │ │ ├── ActivityManager.kt
│ │ │ ├── AndroidAccountManagerCredentialStorage.kt
│ │ │ ├── AccountAuthenticator.kt
│ │ │ └── AuthenticationActivity.kt
│ │ └── AndroidManifest.xml
├── gradle.properties
├── build.gradle.kts
└── README.md
├── gradle
└── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── retroauth
├── gradle.properties
├── src
│ ├── main
│ │ └── java
│ │ │ └── com
│ │ │ └── andretietz
│ │ │ └── retroauth
│ │ │ ├── RequestType.kt
│ │ │ ├── Credentials.kt
│ │ │ ├── AuthenticationRequiredException.kt
│ │ │ ├── AuthenticationCanceledException.kt
│ │ │ ├── Authenticated.kt
│ │ │ ├── Retroauth.kt
│ │ │ ├── CredentialStorage.kt
│ │ │ ├── OwnerStorage.kt
│ │ │ ├── Authenticator.kt
│ │ │ └── CredentialInterceptor.kt
│ └── test
│ │ └── java
│ │ └── com
│ │ └── andretietz
│ │ └── retroauth
│ │ ├── CredentialTest.kt
│ │ ├── MockServerRule.kt
│ │ ├── LockingTest.kt
│ │ └── CredentialInterceptorTest.kt
├── build.gradle.kts
└── README.md
├── .gitignore
├── sqlite
├── gradle.properties
├── src
│ ├── main
│ │ └── kotlin
│ │ │ └── com
│ │ │ └── andretietz
│ │ │ └── retroauth
│ │ │ ├── sqlite
│ │ │ ├── data
│ │ │ │ └── Account.kt
│ │ │ ├── TableDefinitions.kt
│ │ │ └── EntityDefinitions.kt
│ │ │ ├── Main.kt
│ │ │ ├── SQLiteOwnerStore.kt
│ │ │ └── SQLiteCredentialStore.kt
│ └── test
│ │ └── kotlin
│ │ └── com
│ │ └── andretietz
│ │ └── retroauth
│ │ └── SQLiteOwnerStoreTest.kt
└── build.gradle.kts
├── settings.gradle.kts
├── .editorconfig
├── .github
└── workflows
│ ├── pr_build.yml
│ ├── snapshot.yml
│ └── release.yml
├── gradle.properties
├── quality
├── detekt.yml
├── lint.xml
└── checkstyle.xml
├── README.md
├── gradlew.bat
├── gradlew
├── CHANGELOG.md
└── LICENSE
/demo-android/.gitignore:
--------------------------------------------------------------------------------
1 | /build
2 |
--------------------------------------------------------------------------------
/android-accountmanager/proguard-rules.pro:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/android-accountmanager/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker:
--------------------------------------------------------------------------------
1 | mock-maker-inline
2 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/andretietz/retroauth/HEAD/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/retroauth/gradle.properties:
--------------------------------------------------------------------------------
1 | POM_ARTIFACT_ID=retroauth
2 | POM_NAME=Retroauth
3 | POM_DESCRIPTION=Retroauth base project
4 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.iml
2 | .gradle
3 | /local.properties
4 | gradle-local.properties
5 | .idea
6 | .DS_Store
7 | build
8 | /captures
9 |
--------------------------------------------------------------------------------
/demo-android/src/main/ic_launcher-playstore.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/andretietz/retroauth/HEAD/demo-android/src/main/ic_launcher-playstore.png
--------------------------------------------------------------------------------
/demo-android/src/main/res/mipmap-hdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/andretietz/retroauth/HEAD/demo-android/src/main/res/mipmap-hdpi/ic_launcher.png
--------------------------------------------------------------------------------
/demo-android/src/main/res/mipmap-mdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/andretietz/retroauth/HEAD/demo-android/src/main/res/mipmap-mdpi/ic_launcher.png
--------------------------------------------------------------------------------
/demo-android/src/main/res/mipmap-xhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/andretietz/retroauth/HEAD/demo-android/src/main/res/mipmap-xhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/demo-android/src/main/res/mipmap-xxhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/andretietz/retroauth/HEAD/demo-android/src/main/res/mipmap-xxhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/demo-android/src/main/res/mipmap-xxxhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/andretietz/retroauth/HEAD/demo-android/src/main/res/mipmap-xxxhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/sqlite/gradle.properties:
--------------------------------------------------------------------------------
1 | POM_ARTIFACT_ID=sqlite
2 | POM_NAME=Implementation of the Credential- and OwnerStorage using sqlite as persistence.
3 | POM_PACKAGING=jar
4 |
--------------------------------------------------------------------------------
/retroauth/src/main/java/com/andretietz/retroauth/RequestType.kt:
--------------------------------------------------------------------------------
1 | package com.andretietz.retroauth
2 |
3 | data class RequestType(
4 | val credentialType: String
5 | )
6 |
--------------------------------------------------------------------------------
/demo-android/src/main/res/mipmap-hdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/andretietz/retroauth/HEAD/demo-android/src/main/res/mipmap-hdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/demo-android/src/main/res/mipmap-mdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/andretietz/retroauth/HEAD/demo-android/src/main/res/mipmap-mdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/demo-android/src/main/res/mipmap-xhdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/andretietz/retroauth/HEAD/demo-android/src/main/res/mipmap-xhdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/demo-android/src/main/res/mipmap-xxhdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/andretietz/retroauth/HEAD/demo-android/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/settings.gradle.kts:
--------------------------------------------------------------------------------
1 | rootProject.name = "retroauth-root"
2 |
3 | include(
4 | ":retroauth",
5 | ":android-accountmanager",
6 | ":sqlite",
7 | ":demo-android",
8 | )
9 |
--------------------------------------------------------------------------------
/demo-android/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/andretietz/retroauth/HEAD/demo-android/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/demo-android/src/main/res/values/ic_launcher_background.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #FFFFFF
4 |
--------------------------------------------------------------------------------
/sqlite/src/main/kotlin/com/andretietz/retroauth/sqlite/data/Account.kt:
--------------------------------------------------------------------------------
1 | package com.andretietz.retroauth.sqlite.data
2 |
3 | data class Account(
4 | val id: Int,
5 | val name: String,
6 | val email: String
7 | )
8 |
--------------------------------------------------------------------------------
/retroauth/src/main/java/com/andretietz/retroauth/Credentials.kt:
--------------------------------------------------------------------------------
1 | package com.andretietz.retroauth
2 |
3 | class Credentials @JvmOverloads constructor(
4 | val token: String,
5 | val data: Map? = null
6 | )
7 |
--------------------------------------------------------------------------------
/android-accountmanager/gradle.properties:
--------------------------------------------------------------------------------
1 | POM_ARTIFACT_ID=android-accountmanager
2 | POM_NAME=Retroauth: Android Account Manager
3 | POM_DESCRIPTION=Android implementation of retroauth, using the Android AccountManager as Credential- and OwnerStorage
4 |
--------------------------------------------------------------------------------
/demo-android/src/main/res/values/colors.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #3F51B5
4 | #303F9F
5 | #FF4081
6 |
7 |
--------------------------------------------------------------------------------
/demo-android/src/main/res/values/dimens.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | 16dp
4 | 16dp
5 |
6 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | #Mon May 22 11:49:44 PDT 2017
2 | distributionBase=GRADLE_USER_HOME
3 | distributionPath=wrapper/dists
4 | zipStoreBase=GRADLE_USER_HOME
5 | zipStorePath=wrapper/dists
6 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.1.1-bin.zip
7 |
--------------------------------------------------------------------------------
/demo-android/src/main/res/layout/activity_login.xml:
--------------------------------------------------------------------------------
1 |
2 |
6 |
--------------------------------------------------------------------------------
/demo-android/src/main/res/mipmap-anydpi-v26/ic_launcher.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/demo-android/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/demo-android/src/main/res/values-w820dp/dimens.xml:
--------------------------------------------------------------------------------
1 |
2 |
5 | 64dp
6 |
7 |
--------------------------------------------------------------------------------
/demo-android/src/main/res/xml/authenticator.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
--------------------------------------------------------------------------------
/demo-android/src/main/res/values/styles.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/retroauth/src/test/java/com/andretietz/retroauth/CredentialTest.kt:
--------------------------------------------------------------------------------
1 | package com.andretietz.retroauth
2 |
3 | import org.junit.Assert.assertEquals
4 | import org.junit.Test
5 |
6 |
7 | class CredentialTest {
8 | @Test
9 | fun dataCheck() {
10 | val credentials = Credentials("token", mapOf("refresh" to "refresh"))
11 | assertEquals("token", credentials.token)
12 | assertEquals("refresh", requireNotNull(credentials.data)["refresh"])
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/demo-android/src/main/java/com/andretietz/retroauth/demo/RetroauthDemoApplication.kt:
--------------------------------------------------------------------------------
1 | package com.andretietz.retroauth.demo
2 |
3 | import android.app.Application
4 | import dagger.hilt.android.HiltAndroidApp
5 | //import dagger.hilt.android.HiltAndroidApp
6 | import timber.log.Timber
7 |
8 | @HiltAndroidApp
9 | class RetroauthDemoApplication : Application() {
10 | override fun onCreate() {
11 | super.onCreate()
12 | Timber.plant(Timber.DebugTree())
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | # Unix-style newlines with a newline ending every file
4 | [*]
5 | end_of_line = lf
6 | insert_final_newline = true
7 | charset = utf-8
8 | indent_style = space
9 | ij_formatter_tags_enabled = true
10 | ij_formatter_off_tag = @formatter:off
11 | ij_formatter_on_tag = @formatter:on
12 |
13 | [*.{kt, kts, java}]
14 | indent_size = 2
15 | trim_trailing_whitespace = true
16 | # no star imports!
17 | ij_kotlin_name_count_to_use_star_import = 999
18 | ij_kotlin_name_count_to_use_star_import_for_members = 999
19 |
--------------------------------------------------------------------------------
/demo-android/src/main/java/com/andretietz/retroauth/demo/screen/main/MainViewState.kt:
--------------------------------------------------------------------------------
1 | package com.andretietz.retroauth.demo.screen.main
2 |
3 | import com.andretietz.retroauth.demo.api.GithubApi
4 |
5 | sealed class MainViewState {
6 | object InitialState : MainViewState()
7 | data class LoginSuccess(val account: OWNER) : MainViewState()
8 | object LogoutSuccess : MainViewState()
9 | data class Error(val throwable: Throwable) : MainViewState()
10 | class RepositoryUpdate(
11 | val repos: List
12 | ) : MainViewState()
13 | }
14 |
--------------------------------------------------------------------------------
/retroauth/src/test/java/com/andretietz/retroauth/MockServerRule.kt:
--------------------------------------------------------------------------------
1 | package com.andretietz.retroauth
2 |
3 | import okhttp3.mockwebserver.MockWebServer
4 | import org.junit.rules.TestRule
5 | import org.junit.runner.Description
6 | import org.junit.runners.model.Statement
7 |
8 | class MockServerRule : TestRule {
9 | val server = MockWebServer()
10 | override fun apply(base: Statement, description: Description) = object : Statement() {
11 | override fun evaluate() {
12 | server.start()
13 | base.evaluate()
14 | server.shutdown()
15 | }
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/demo-android/src/main/res/drawable/ic_baseline_lock_24.xml:
--------------------------------------------------------------------------------
1 |
7 |
10 |
11 |
--------------------------------------------------------------------------------
/demo-android/src/main/res/drawable/ic_baseline_lock_open_24.xml:
--------------------------------------------------------------------------------
1 |
7 |
10 |
11 |
--------------------------------------------------------------------------------
/demo-android/src/main/res/menu/menu.xml:
--------------------------------------------------------------------------------
1 |
2 |
17 |
--------------------------------------------------------------------------------
/demo-android/src/main/java/com/andretietz/retroauth/demo/api/GithubApi.kt:
--------------------------------------------------------------------------------
1 | package com.andretietz.retroauth.demo.api
2 |
3 | import com.andretietz.retroauth.Authenticated
4 | import com.squareup.moshi.JsonClass
5 | import retrofit2.http.GET
6 |
7 | interface GithubApi {
8 |
9 | /**
10 | * https://docs.github.com/en/rest/reference/repos#list-repositories-for-the-authenticated-user
11 | */
12 | @Authenticated
13 | @GET("user/repos?visibility=all")
14 | suspend fun getRepositories(): List
15 |
16 | @JsonClass(generateAdapter = true)
17 | data class Repository(
18 | val id: String,
19 | val name: String,
20 | val url: String,
21 | val private: Boolean
22 | )
23 | }
24 |
--------------------------------------------------------------------------------
/android-accountmanager/src/main/java/com/andretietz/retroauth/RetroauthInitProvider.kt:
--------------------------------------------------------------------------------
1 | package com.andretietz.retroauth
2 |
3 | import android.app.Application
4 | import android.content.Context
5 | import androidx.startup.Initializer
6 |
7 | internal class RetroauthInitProvider : Initializer {
8 | override fun create(context: Context): ActivityManager {
9 | context.takeIf { it.applicationContext is Application }?.let {
10 | return ActivityManager[it.applicationContext as Application]
11 | }
12 | throw error("Could not initialize retroauth. Context is not an application!")
13 | }
14 |
15 | override fun dependencies(): MutableList>> = mutableListOf()
16 | }
17 |
--------------------------------------------------------------------------------
/retroauth/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | kotlin("jvm")
3 | id("com.vanniktech.maven.publish")
4 | }
5 |
6 | dependencies {
7 | api(Dependencies.kotlin.kotlin)
8 | api(Dependencies.retrofit.retrofit)
9 | api(Dependencies.okhttp.okhttp)
10 |
11 | testImplementation(Dependencies.test.junit)
12 | testImplementation(Dependencies.test.coroutines)
13 | testImplementation(kotlin("reflect", version = Versions.kotlin))
14 | testImplementation(Dependencies.okhttp.mockwebserver)
15 | testImplementation(Dependencies.retrofit.moshiConverter)
16 | testImplementation(Dependencies.retrofit.gsonConverter)
17 | testImplementation(Dependencies.test.mockitoKotlin)
18 | testImplementation(Dependencies.test.assertj)
19 | }
20 |
--------------------------------------------------------------------------------
/demo-android/src/main/java/com/andretietz/retroauth/demo/auth/DemoAuthenticationService.kt:
--------------------------------------------------------------------------------
1 | package com.andretietz.retroauth.demo.auth
2 |
3 | import android.accounts.Account
4 | import com.andretietz.retroauth.AuthenticationService
5 | import com.andretietz.retroauth.demo.R
6 | import timber.log.Timber
7 |
8 | class DemoAuthenticationService : AuthenticationService() {
9 | override fun getLoginAction(): String = getString(R.string.authentication_ACTION)
10 | override fun cleanupAccount(account: Account) {
11 | // Here you can trigger your account cleanup.
12 | // Note: This might be executed on a different process! (when the account is removed
13 | // from the android account manager.
14 | Timber.e("Remove account: ${account.name}")
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/demo-android/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 | Retroauth Demo
3 | com.andretietz.retroauth.demo.ACTION
4 | com.andretietz.retroauth.demo.ACCOUNT
5 | com.andretietz.retroauth.demo.TOKEN
6 | Add Account
7 | Invalidate Token
8 | Switch Account
9 | Logout
10 | To load repositories, pull to load/refresh
11 |
12 |
--------------------------------------------------------------------------------
/android-accountmanager/src/test/java/com/andretietz/retroauth/ActivityManagerTest.kt:
--------------------------------------------------------------------------------
1 | package com.andretietz.retroauth
2 |
3 | import android.app.Application
4 | import androidx.test.core.app.ApplicationProvider
5 | import androidx.test.ext.junit.runners.AndroidJUnit4
6 | import org.assertj.core.api.Assertions.assertThat
7 | import org.junit.Before
8 | import org.junit.Test
9 | import org.junit.runner.RunWith
10 |
11 | @RunWith(AndroidJUnit4::class)
12 | class ActivityManagerTest {
13 | private val application = ApplicationProvider.getApplicationContext()
14 |
15 | @Before
16 | fun setup() {
17 | ActivityManager[application]
18 | }
19 |
20 | @Test
21 | fun initializing() {
22 | val activityManager = ActivityManager[application]
23 | assertThat(activityManager).isNotNull
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/.github/workflows/pr_build.yml:
--------------------------------------------------------------------------------
1 | name: PR build
2 |
3 | on: [pull_request]
4 |
5 | jobs:
6 | build:
7 | name: PR Build
8 | runs-on: ubuntu-latest
9 |
10 | steps:
11 | - name: Checkout
12 | uses: actions/checkout@v2
13 |
14 | - name: Gradle Wrapper Validation
15 | uses: gradle/wrapper-validation-action@v1
16 |
17 | - name: Install JDK
18 | uses: actions/setup-java@v1
19 | with:
20 | java-version: 11
21 |
22 | - name: Build
23 | run: |
24 | version=$(grep "VERSION_NAME" gradle.properties | cut -d'=' -f2 )
25 | if [[ $version != *"-SNAPSHOT"* ]]; then
26 | echo "Version string MUST contain \"-SNAPSHOT\"!"
27 | exit 1;
28 | fi
29 | echo "Next Version: $version"
30 | ./gradlew build --no-daemon --no-parallel --stacktrace --warning-mode all
31 |
--------------------------------------------------------------------------------
/android-accountmanager/src/main/java/com/andretietz/retroauth/RetroauthAndroid.kt:
--------------------------------------------------------------------------------
1 | package com.andretietz.retroauth
2 |
3 | import android.accounts.Account
4 | import android.app.Application
5 | import retrofit2.Retrofit
6 |
7 | object RetroauthAndroid {
8 | @JvmStatic
9 | fun setup(
10 | retrofit: Retrofit,
11 | application: Application,
12 | authenticator: Authenticator,
13 | ownerType: String
14 | ): Retrofit {
15 | return Retroauth.setup(
16 | retrofit,
17 | authenticator,
18 | AndroidAccountManagerOwnerStorage(application, ownerType),
19 | AndroidAccountManagerCredentialStorage(application)
20 | )
21 | }
22 | }
23 |
24 | fun Retrofit.androidAuthentication(
25 | application: Application,
26 | authenticator: Authenticator,
27 | ownerType: String
28 | ) = RetroauthAndroid.setup(this, application, authenticator, ownerType)
29 |
--------------------------------------------------------------------------------
/gradle.properties:
--------------------------------------------------------------------------------
1 | GROUP=com.andretietz.retroauth
2 | VERSION_NAME=4.0.0-SNAPSHOT
3 | POM_DESCRIPTION=A library build on top of retrofit, for simple handling of authenticated requests.
4 | POM_URL=https://github.com/andretietz/retroauth
5 | POM_SCM_URL=https://github.com/andretietz/retroauth
6 | POM_SCM_CONNECTION=scm:git:git://github.com/andretietz/retroauth.git
7 | POM_SCM_DEV_CONNECTION=scm:git:git://github.com/andretietz/retroauth.git
8 | POM_LICENCE_NAME=The Apache Software License, Version 2.0
9 | POM_LICENCE_URL=http://www.apache.org/licenses/LICENSE-2.0.txt
10 | POM_LICENCE_DIST=repo
11 | POM_DEVELOPER_ID=andretietz
12 | POM_DEVELOPER_URL=https://github.com/andretietz/
13 | POM_DEVELOPER_NAME=Andre Tietz
14 | POM_INCEPTION_YEAR=2016
15 |
16 | android.useAndroidX=true
17 | android.enableJetifier=false
18 | systemProp.org.gradle.internal.http.socketTimeout=120000
19 | org.gradle.jvmargs=-Xmx4608M
20 |
--------------------------------------------------------------------------------
/sqlite/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | kotlin("jvm")
3 | // TODO: do not publish yet
4 | // id("com.vanniktech.maven.publish")
5 | }
6 |
7 | dependencies {
8 | api(Dependencies.kotlin.kotlin)
9 | implementation(project(":retroauth"))
10 | implementation("org.jetbrains.exposed:exposed-core:0.32.1")
11 | implementation("org.jetbrains.exposed:exposed-dao:0.32.1")
12 | implementation("org.jetbrains.exposed:exposed-jdbc:0.32.1")
13 | implementation("org.xerial:sqlite-jdbc:3.36.0.1")
14 |
15 | testImplementation(Dependencies.test.mockitoKotlin)
16 | testImplementation(Dependencies.test.assertj)
17 | testImplementation(Dependencies.test.junit)
18 | testImplementation(Dependencies.test.coroutines)
19 | testImplementation(kotlin("reflect", version = Versions.kotlin))
20 | // https://mvnrepository.com/artifact/com.h2database/h2
21 | testImplementation("com.h2database:h2:1.4.200")
22 |
23 | }
24 |
25 |
--------------------------------------------------------------------------------
/sqlite/src/main/kotlin/com/andretietz/retroauth/sqlite/TableDefinitions.kt:
--------------------------------------------------------------------------------
1 | package com.andretietz.retroauth.sqlite
2 |
3 | import org.jetbrains.exposed.dao.id.IntIdTable
4 |
5 |
6 | internal object UserTable : IntIdTable() {
7 | val active = bool("active").default(false)
8 | val name = varchar("name", 100)
9 | val email = varchar("email", 100)
10 |
11 | init {
12 | uniqueIndex("IDX_user", name, email)
13 | }
14 | }
15 |
16 | internal object CredentialTable : IntIdTable() {
17 | val user = reference("user_id", UserTable)
18 | val type = varchar("key", 200)
19 | val value = text("value")
20 | }
21 |
22 | internal object DataTable : IntIdTable() {
23 | val credential = reference("credential_id", CredentialTable)
24 | val user = reference("user_id", UserTable)
25 | val key = varchar("key", 200)
26 | val value = text("value")
27 | override val primaryKey = PrimaryKey(credential, user, name = "PK_data_user_credential")
28 | }
29 |
--------------------------------------------------------------------------------
/quality/detekt.yml:
--------------------------------------------------------------------------------
1 | potential-bugs:
2 | UnsafeCast:
3 | active: false
4 | LateinitUsage:
5 | active: false
6 |
7 | complexity:
8 | NestedBlockDepth:
9 | active: false
10 | MethodOverloading:
11 | active: false
12 | ComplexMethod:
13 | active: false
14 | LongMethod:
15 | excludes: "**/*Test.kt"
16 |
17 | exceptions:
18 | TooGenericExceptionCaught:
19 | active: false
20 | TooGenericExceptionThrown:
21 | active: false
22 |
23 | style:
24 | ReturnCount:
25 | active: false
26 | UnnecessaryAbstractClass:
27 | active: false
28 | UseDataClass:
29 | active: false
30 | MandatoryBracesIfStatements:
31 | active: false
32 |
33 | comments:
34 | UndocumentedPublicClass:
35 | active: false
36 | UndocumentedPublicFunction:
37 | active: false
38 | CommentOverPrivateFunction:
39 | active: false
40 | EndOfSentenceFormat:
41 | active: false
42 |
43 | naming:
44 | PackageNaming:
45 | active: false
--------------------------------------------------------------------------------
/quality/lint.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
--------------------------------------------------------------------------------
/retroauth/src/main/java/com/andretietz/retroauth/AuthenticationRequiredException.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (c) 2016 Andre Tietz
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.andretietz.retroauth
18 |
19 | import java.io.IOException
20 |
21 | /**
22 | * This Exception is thrown, when the user requires to login in order to fulfill an action.
23 | */
24 | class AuthenticationRequiredException @JvmOverloads constructor(
25 | detailMessage: String? = null,
26 | throwable: Throwable? = null
27 | ) : IOException(detailMessage, throwable)
28 |
--------------------------------------------------------------------------------
/.github/workflows/snapshot.yml:
--------------------------------------------------------------------------------
1 | name: Snapshot build
2 |
3 | on:
4 | push:
5 | branches:
6 | - master
7 |
8 | jobs:
9 | build:
10 | name: Snapshot Build
11 | runs-on: ubuntu-latest
12 |
13 | steps:
14 | - name: Checkout
15 | uses: actions/checkout@v2
16 |
17 | - name: Gradle Wrapper Validation
18 | uses: gradle/wrapper-validation-action@v1
19 |
20 | - name: Install JDK
21 | uses: actions/setup-java@v1
22 | with:
23 | java-version: 11
24 |
25 | - name: Building Snapshot
26 | run: |
27 | version=$(grep "VERSION_NAME" gradle.properties | cut -d'=' -f2 )
28 | if [[ $version != *"-SNAPSHOT"* ]]; then
29 | echo "Version string MUST contain \"-SNAPSHOT\"!"
30 | exit 1;
31 | fi
32 | echo "Building Snapshot Version: $version"
33 | ./gradlew publish --no-daemon --no-parallel --stacktrace --warning-mode all
34 | env:
35 | ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.SONATYPE_NEXUS_USERNAME }}
36 | ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.SONATYPE_NEXUS_PASSWORD }}
37 |
--------------------------------------------------------------------------------
/retroauth/src/main/java/com/andretietz/retroauth/AuthenticationCanceledException.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (c) 2016 Andre Tietz
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.andretietz.retroauth
18 |
19 | import java.io.IOException
20 |
21 | /**
22 | * This Exception is thrown, when the user cancels the Authentication or
23 | * some other error happens. The Reason can be read, on calling [AuthenticationCanceledException.getCause]
24 | */
25 | class AuthenticationCanceledException @JvmOverloads constructor(
26 | detailMessage: String? = null,
27 | throwable: Throwable? = null
28 | ) : IOException(detailMessage, throwable)
29 |
--------------------------------------------------------------------------------
/retroauth/src/main/java/com/andretietz/retroauth/Authenticated.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (c) 2016 Andre Tietz
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.andretietz.retroauth
18 |
19 | import kotlin.annotation.AnnotationRetention.RUNTIME
20 | import kotlin.annotation.AnnotationTarget.FUNCTION
21 | import kotlin.annotation.AnnotationTarget.PROPERTY_GETTER
22 | import kotlin.annotation.AnnotationTarget.PROPERTY_SETTER
23 |
24 | /**
25 | * This is the annotation you can use to authorize your request.
26 | */
27 | @Target(FUNCTION, PROPERTY_GETTER, PROPERTY_SETTER)
28 | @Retention(RUNTIME)
29 | annotation class Authenticated(
30 | val credentialType: Int = 0
31 | )
32 |
--------------------------------------------------------------------------------
/sqlite/src/main/kotlin/com/andretietz/retroauth/sqlite/EntityDefinitions.kt:
--------------------------------------------------------------------------------
1 | package com.andretietz.retroauth.sqlite
2 |
3 | import org.jetbrains.exposed.dao.IntEntity
4 | import org.jetbrains.exposed.dao.IntEntityClass
5 | import org.jetbrains.exposed.dao.id.EntityID
6 |
7 | internal class DatabaseUser(id: EntityID) : IntEntity(id) {
8 | companion object : IntEntityClass(UserTable)
9 |
10 | var name by UserTable.name
11 | var email by UserTable.email
12 | var active by UserTable.active
13 | }
14 |
15 | /**
16 | * This table should be encrypted at one point.
17 | */
18 | internal class DatabaseCredential(id: EntityID) : IntEntity(id) {
19 | companion object : IntEntityClass(CredentialTable)
20 |
21 | var user by DatabaseUser referencedOn CredentialTable.user
22 | var type by CredentialTable.type
23 | var value by CredentialTable.value
24 | }
25 |
26 | internal class DatabaseData(id: EntityID) : IntEntity(id) {
27 | companion object : IntEntityClass(DataTable)
28 |
29 | var user by DatabaseUser referencedOn DataTable.user
30 | var credential by DatabaseCredential referencedOn DataTable.credential
31 | var key by DataTable.key
32 | var value by DataTable.value
33 | }
34 |
--------------------------------------------------------------------------------
/android-accountmanager/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
4 |
5 |
8 |
11 |
14 |
17 |
18 |
19 |
24 |
27 |
28 |
29 |
30 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # A simple way of calling authenticated requests using retrofit
2 | [](https://github.com/andretietz/retroauth/actions?query=workflow%3A%22Snapshot+build%22)
3 |
4 |
5 | I split the project into 2 separate ones.
6 |
7 | * [retroauth](retroauth)
8 | This is the base implementation, to be used in plain java/kotlin projects.
9 | * [android-accountmanager](android-accountmanager/)
10 | On top of the pure Kotlin implementation there's the Android implementation, which uses the
11 | Android AccountManager in order to store Owners (Accounts) and their Credentials.
12 |
13 | ## LICENSE
14 | ```
15 | Copyrights 2016 André Tietz
16 |
17 | Licensed under the Apache License, Version 2.0 (the "License");
18 | you may not use this file except in compliance with the License.
19 | You may obtain a copy of the License at
20 |
21 |
22 |
23 | Unless required by applicable law or agreed to in writing, software
24 | distributed under the License is distributed on an "AS IS" BASIS,
25 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
26 | See the License for the specific language governing permissions and
27 | limitations under the License.
28 | ```
29 |
--------------------------------------------------------------------------------
/android-accountmanager/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | id("com.android.library")
3 | kotlin("android")
4 | id("com.vanniktech.maven.publish")
5 | }
6 |
7 | android {
8 | compileSdk = 31
9 | defaultConfig {
10 | minSdk = 21
11 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
12 | }
13 | buildTypes {
14 | getByName("release") {
15 | proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
16 | }
17 | }
18 | compileOptions {
19 | sourceCompatibility = JavaVersion.VERSION_1_8
20 | targetCompatibility = JavaVersion.VERSION_1_8
21 | }
22 |
23 | testOptions.unitTests.isIncludeAndroidResources = true
24 | buildFeatures.buildConfig = false
25 | }
26 |
27 |
28 | dependencies {
29 | implementation(Dependencies.android.appcompat)
30 | implementation(Dependencies.android.startup)
31 | api(project(":retroauth"))
32 |
33 | testImplementation(Dependencies.test.junit)
34 | testImplementation(Dependencies.test.mockito)
35 | testImplementation(Dependencies.android.test.core)
36 | testImplementation(Dependencies.android.test.junit)
37 | testImplementation(Dependencies.android.test.robolectric)
38 | testImplementation(Dependencies.android.test.runner)
39 | testImplementation(Dependencies.android.test.rules)
40 | testImplementation(Dependencies.test.assertj)
41 | }
42 |
43 | tasks.withType {
44 | kotlinOptions.jvmTarget = "1.8"
45 | }
46 |
--------------------------------------------------------------------------------
/demo-android/src/main/res/drawable/ic_launcher_foreground.xml:
--------------------------------------------------------------------------------
1 |
6 |
10 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/demo-android/src/main/java/com/andretietz/retroauth/demo/auth/GithubAuthenticator.kt:
--------------------------------------------------------------------------------
1 | package com.andretietz.retroauth.demo.auth
2 |
3 | import android.accounts.Account
4 | import android.app.Application
5 | import android.content.Context
6 | import com.andretietz.retroauth.Authenticator
7 | import com.andretietz.retroauth.Credentials
8 | import com.andretietz.retroauth.demo.R
9 | import okhttp3.Request
10 |
11 | /**
12 | * This is an optimistic implementation of facebook as [Authenticator].
13 | *
14 | * If the credential for some reason is invalid, the returning 401 will cause the deletion of the credential and a retry of the
15 | * call, in which it will get refreshed
16 | */
17 | class GithubAuthenticator(private val application: Application) : Authenticator() {
18 |
19 | companion object {
20 | const val CLIENT_ID = "bb86ddeb2dd22163192f"
21 | const val CLIENT_SECRET = "0b2a017a3e481c1cb69739ff5a6c4de37009ed7a"
22 | const val CLIENT_CALLBACK = "https://localhost:8000/accounts/github/login/callback/"
23 |
24 | @JvmStatic
25 | fun createTokenType(context: Context) = context.getString(R.string.authentication_TOKEN)
26 | }
27 |
28 | private val credentialType = createTokenType(application)
29 |
30 | override fun getCredentialType(credentialType: Int): String = this.credentialType
31 |
32 | override fun authenticateRequest(request: Request, credential: Credentials): Request {
33 | return request.newBuilder()
34 | .header("Authorization", "Bearer ${credential.token}")
35 | .build()
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/demo-android/src/main/res/layout/activity_main.xml:
--------------------------------------------------------------------------------
1 |
12 |
13 |
18 |
19 |
24 |
25 |
30 |
31 |
36 |
37 |
42 |
43 |
--------------------------------------------------------------------------------
/android-accountmanager/src/test/java/com/andretietz/retroauth/WeakActivityStackTest.kt:
--------------------------------------------------------------------------------
1 | package com.andretietz.retroauth
2 |
3 | import android.app.Activity
4 | import androidx.test.ext.junit.runners.AndroidJUnit4
5 | import org.junit.Assert.assertEquals
6 | import org.junit.Test
7 | import org.junit.runner.RunWith
8 | import org.mockito.Mockito.mock
9 |
10 | @RunWith(AndroidJUnit4::class)
11 | class WeakActivityStackTest {
12 |
13 | private val stack = WeakActivityStack()
14 |
15 | @Test
16 | fun basicStackTest() {
17 | val activity1 = mock(Activity::class.java)
18 | val activity2 = mock(Activity::class.java)
19 | val activity3 = mock(Activity::class.java)
20 |
21 | stack.push(activity1)
22 | stack.push(activity2)
23 | stack.push(activity3)
24 |
25 | assertEquals(activity3, stack.pop())
26 | assertEquals(activity2, stack.pop())
27 | assertEquals(activity1, stack.pop())
28 | }
29 |
30 | @Test
31 | fun stackRemovalTest() {
32 | val activity1 = mock(Activity::class.java)
33 | val activity2 = mock(Activity::class.java)
34 | val activity3 = mock(Activity::class.java)
35 |
36 | stack.push(activity1)
37 | stack.push(activity2)
38 | stack.push(activity3)
39 |
40 | stack.remove(activity2)
41 |
42 | assertEquals(activity3, stack.pop())
43 | assertEquals(activity1, stack.pop())
44 | }
45 |
46 | @Test
47 | fun splashScreenTest() {
48 | val splashScreen = mock(Activity::class.java)
49 | val mainScreen = mock(Activity::class.java)
50 |
51 | stack.push(splashScreen)
52 | stack.push(mainScreen)
53 |
54 | stack.remove(splashScreen)
55 |
56 | assertEquals(mainScreen, stack.peek())
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/demo-android/src/main/res/layout/listitem_repository.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
22 |
23 |
33 |
34 |
--------------------------------------------------------------------------------
/sqlite/src/main/kotlin/com/andretietz/retroauth/Main.kt:
--------------------------------------------------------------------------------
1 | //package com.andretietz.retroauth
2 | //
3 | //import com.andretietz.retroauth.sqlite.CredentialTable
4 | //import com.andretietz.retroauth.sqlite.DataTable
5 | //import com.andretietz.retroauth.sqlite.DatabaseCredential
6 | //import com.andretietz.retroauth.sqlite.DatabaseUser
7 | //import com.andretietz.retroauth.sqlite.UserTable
8 | //import com.andretietz.retroauth.sqlite.data.Account
9 | //import org.jetbrains.exposed.sql.Database
10 | //import org.jetbrains.exposed.sql.SchemaUtils
11 | //import org.jetbrains.exposed.sql.StdOutSqlLogger
12 | //import org.jetbrains.exposed.sql.addLogger
13 | //import org.jetbrains.exposed.sql.selectAll
14 | //import org.jetbrains.exposed.sql.transactions.transaction
15 | //private const val OWNER_TYPE = "ownertype"
16 | //
17 | //fun main() {
18 | // val database = Database.connect("jdbc:h2:mem:test", driver = "org.h2.Driver")
19 | //// Database.connect(
20 | //// "jdbc:sqlite:accountdb.db",
21 | //// user = "someuser",
22 | //// password = "somepassword",
23 | //// driver = "org.sqlite.JDBC"
24 | //// )
25 | //
26 | // val credentialStore = SQLiteCredentialStore(database)
27 | // val ownerStore: OwnerStorage = SQLiteOwnerStore(database)
28 | //
29 | // transaction(database) {
30 | // addLogger(StdOutSqlLogger)
31 | // SchemaUtils.create(UserTable, CredentialTable, DataTable)
32 | //
33 | // ownerStore.createOwner(CREDENTIAL_TYPE)?.let {
34 | // credentialStore.storeCredentials(it, CREDENTIAL_TYPE, Credentials("myfancytoken"))
35 | //
36 | // val credential = credentialStore.getCredentials(it, CREDENTIAL_TYPE)
37 | // println(credential)
38 | // }
39 | //
40 | // SchemaUtils.drop(UserTable, CredentialTable, DataTable)
41 | // }
42 | //}
43 | //
44 | //
45 |
--------------------------------------------------------------------------------
/demo-android/src/main/java/com/andretietz/retroauth/demo/screen/main/SwitchAccountContract.kt:
--------------------------------------------------------------------------------
1 | package com.andretietz.retroauth.demo.screen.main
2 |
3 | import android.accounts.Account
4 | import android.accounts.AccountManager
5 | import android.content.Context
6 | import android.content.Intent
7 | import android.webkit.CookieManager
8 | import androidx.activity.result.contract.ActivityResultContract
9 | import androidx.appcompat.app.AppCompatActivity
10 | import com.andretietz.retroauth.demo.R
11 | import com.andretietz.retroauth.demo.auth.LoginActivity
12 |
13 | class SwitchAccountContract : ActivityResultContract() {
14 | override fun createIntent(context: Context, input: Account?): Intent {
15 | CookieManager.getInstance().removeAllCookies(null)
16 | return if (input != null) {
17 | // this method is the reason for min API 23
18 | AccountManager.newChooseAccountIntent(
19 | input,
20 | null,
21 | arrayOf(input.type),
22 | null,
23 | null,
24 | null,
25 | null
26 | )
27 | } else {
28 | Intent(context, LoginActivity::class.java).also {
29 | it.putExtra(
30 | AccountManager.KEY_ACCOUNT_TYPE,
31 | context.getText(R.string.authentication_ACCOUNT)
32 | )
33 | }
34 | }
35 | }
36 |
37 |
38 | override fun parseResult(resultCode: Int, data: Intent?): Account? {
39 | return if (resultCode == AppCompatActivity.RESULT_OK) {
40 | val accountType = data?.getStringExtra(AccountManager.KEY_ACCOUNT_TYPE)
41 | val accountName = data?.getStringExtra(AccountManager.KEY_ACCOUNT_NAME)
42 | if (accountType != null && accountName != null) {
43 | Account(accountName, accountType)
44 | } else null
45 | } else null
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/demo-android/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
5 |
6 |
7 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
30 |
31 |
32 |
33 |
36 |
37 |
38 |
39 |
40 |
--------------------------------------------------------------------------------
/retroauth/src/main/java/com/andretietz/retroauth/Retroauth.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (c) 2016 Andre Tietz
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.andretietz.retroauth
18 |
19 | import okhttp3.OkHttpClient
20 | import retrofit2.Retrofit
21 |
22 | /**
23 | * This is the wrapper builder to create the [Retrofit] object.
24 | */
25 | object Retroauth {
26 | fun setup(
27 | retrofit: Retrofit,
28 | authenticator: Authenticator,
29 | ownerStorage: OwnerStorage,
30 | credentialStorage: CredentialStorage
31 | ): Retrofit {
32 | val okHttpClient = retrofit.callFactory().let { callFactory ->
33 | check(callFactory is OkHttpClient) { "Retroauth only works with OkHttp as Http Client!" }
34 | callFactory.newBuilder()
35 | .addInterceptor(CredentialInterceptor(authenticator, ownerStorage, credentialStorage))
36 | .build()
37 | }
38 | return retrofit.newBuilder()
39 | .client(okHttpClient)
40 | .build()
41 | }
42 | }
43 |
44 | fun Retrofit.authentication(
45 | authenticator: Authenticator,
46 | ownerStorage: OwnerStorage,
47 | credentialStorage: CredentialStorage
48 | ): Retrofit = Retroauth.setup(this, authenticator, ownerStorage, credentialStorage)
49 |
--------------------------------------------------------------------------------
/retroauth/src/main/java/com/andretietz/retroauth/CredentialStorage.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (c) 2016 Andre Tietz
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.andretietz.retroauth
18 |
19 | /**
20 | * This is the interface of a credential storage.
21 | */
22 | interface CredentialStorage {
23 |
24 | /**
25 | * This method returns an authentication credential that is stored locally.
26 | *
27 | * @param owner The owner type of the credential you want to get
28 | * @param credentialType the type of the Credentials you want to get
29 | * @return the Credentials to authenticate your request with
30 | */
31 | fun getCredentials(
32 | owner: OWNER,
33 | credentialType: String
34 | ): Credentials?
35 |
36 | /**
37 | * Removes the credentials of a specific type and owner from the credentials storage.
38 | *
39 | * @param owner Owner of the Credentials
40 | * @param credentialType Type of the Credentials
41 | */
42 | fun removeCredentials(owner: OWNER, credentialType: String)
43 |
44 | /**
45 | * Stores a credentials of a specific type and owner to the credentials storage.
46 | *
47 | * @param owner Owner of the Credentials
48 | * @param credentialType Type of the Credentials
49 | * @param credentials Credentials to store
50 | */
51 | fun storeCredentials(owner: OWNER, credentialType: String, credentials: Credentials)
52 | }
53 |
--------------------------------------------------------------------------------
/sqlite/src/main/kotlin/com/andretietz/retroauth/SQLiteOwnerStore.kt:
--------------------------------------------------------------------------------
1 | package com.andretietz.retroauth
2 |
3 | import com.andretietz.retroauth.sqlite.DatabaseUser
4 | import com.andretietz.retroauth.sqlite.UserTable
5 | import com.andretietz.retroauth.sqlite.data.Account
6 | import org.jetbrains.exposed.sql.Database
7 | import org.jetbrains.exposed.sql.transactions.transaction
8 |
9 | class SQLiteOwnerStore(
10 | private val database: Database,
11 | private val showCreateOwnerUI: (credentialType: String) -> Account?
12 | ) : OwnerStorage {
13 |
14 | override fun createOwner(credentialType: String): Account? {
15 | return showCreateOwnerUI(credentialType)
16 | ?.also { switchActiveOwner(it) }
17 | }
18 |
19 |
20 | override fun getOwner(ownerName: String): Account? = transaction(database) {
21 | DatabaseUser.find { UserTable.name eq ownerName }
22 | .firstOrNull()
23 | ?.let { Account(it.id.value, it.name, it.email) }
24 | }
25 |
26 | override fun getActiveOwner(): Account? = transaction(database) {
27 | DatabaseUser.find { UserTable.active eq true }
28 | .firstOrNull()
29 | ?.let { Account(it.id.value, it.name, it.email) }
30 | }
31 |
32 |
33 | override fun getOwners(): List = transaction(database) {
34 | DatabaseUser.all().map { Account(it.id.value, it.name, it.email) }
35 | }
36 |
37 |
38 | override fun switchActiveOwner(owner: Account?) {
39 | transaction(database) {
40 | DatabaseUser.all().map {
41 | it.active = (owner != null &&
42 | it.name == owner.name &&
43 | it.email == owner.email)
44 | }
45 | }
46 | }
47 |
48 | override fun removeOwner(owner: Account): Boolean {
49 | return transaction(database) {
50 | DatabaseUser.find { UserTable.name eq owner.name }
51 | // TODO remove credentials and data also!
52 | .map { it.delete() }
53 | .toList()
54 | .isNotEmpty()
55 | }
56 | }
57 |
58 | }
59 |
--------------------------------------------------------------------------------
/android-accountmanager/src/main/java/com/andretietz/retroauth/AuthenticationService.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (c) 2016 Andre Tietz
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.andretietz.retroauth
18 |
19 | import android.accounts.Account
20 | import android.app.Service
21 | import android.content.Intent
22 | import android.os.IBinder
23 |
24 | /**
25 | * You have to extend this service if you want to provide your own implementation of the [AuthenticationService].
26 | */
27 | abstract class AuthenticationService : Service() {
28 |
29 | override fun onBind(intent: Intent): IBinder? = AccountAuthenticator(
30 | this,
31 | getLoginAction(),
32 | this::cleanupAccount
33 | ).iBinder
34 |
35 | /**
36 | * @return An Action String to open the activity to login
37 | */
38 | abstract fun getLoginAction(): String
39 |
40 | /**
41 | * Called when an account is intended to be removed. Use it if you need to remove any kinds of user related data.
42 | *
43 | * At this point the account hasn't been removed yet. It will right after this method has been executed. If
44 | *
45 | * Caution: This method can (and will) be called from a different process (when the user removes the account
46 | * using the account settings on the android device). Consider that when cleaning up your user data.
47 | *
48 | * @param account that will be removed.
49 | */
50 | open fun cleanupAccount(account: Account) {}
51 | }
52 |
--------------------------------------------------------------------------------
/android-accountmanager/src/main/java/com/andretietz/retroauth/WeakActivityStack.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (c) 2016 Andre Tietz
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.andretietz.retroauth
18 |
19 | import android.app.Activity
20 | import android.util.SparseArray
21 |
22 | import java.lang.ref.WeakReference
23 | import java.util.LinkedList
24 |
25 | internal class WeakActivityStack {
26 |
27 | private val map = SparseArray>()
28 |
29 | private val stack = LinkedList()
30 |
31 | fun push(item: Activity) {
32 | val identifier = getIdentifier(item)
33 | synchronized(this) {
34 | stack.push(identifier)
35 | map.put(identifier, WeakReference(item))
36 | }
37 | }
38 |
39 | fun pop(): Activity? {
40 | synchronized(this) {
41 | if (!stack.isEmpty()) {
42 | val identifier = stack.removeFirst()
43 | val item = map.get(requireNotNull(identifier)).get()
44 | map.remove(identifier)
45 | return item
46 | }
47 | return null
48 | }
49 | }
50 |
51 | fun remove(item: Activity) {
52 | val identifier = getIdentifier(item)
53 | synchronized(this) {
54 | stack.remove(identifier)
55 | map.remove(identifier)
56 | }
57 | }
58 |
59 | fun peek(): Activity? {
60 | synchronized(this) {
61 | if (!stack.isEmpty()) {
62 | return map.get(stack.first).get()
63 | }
64 | }
65 | return null
66 | }
67 |
68 | private fun getIdentifier(item: Activity) = item.hashCode()
69 | }
70 |
--------------------------------------------------------------------------------
/demo-android/src/main/res/layout/activity_repository_list.xml:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
10 |
18 |
19 |
23 |
24 |
30 |
31 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
--------------------------------------------------------------------------------
/retroauth/src/test/java/com/andretietz/retroauth/LockingTest.kt:
--------------------------------------------------------------------------------
1 | package com.andretietz.retroauth
2 |
3 | import kotlinx.coroutines.Dispatchers
4 | import kotlinx.coroutines.launch
5 | import kotlinx.coroutines.runBlocking
6 | import kotlinx.coroutines.sync.Mutex
7 | import kotlinx.coroutines.withContext
8 | import okhttp3.internal.wait
9 | import org.assertj.core.api.Assertions.assertThat
10 | import org.junit.Test
11 | import java.util.concurrent.atomic.AtomicInteger
12 | import java.util.concurrent.atomic.AtomicReference
13 |
14 | class LockingTest {
15 |
16 |
17 | @Test
18 | fun `locking mechanics`() = runBlocking {
19 | var c = 0
20 | val a = ApiEmulator {
21 | // println("execute: ${Thread.currentThread().id}")
22 | Thread.sleep(500)
23 | c++
24 | error("expected")
25 | }
26 | launch {
27 | repeat(200) {
28 | launch (Dispatchers.IO){
29 | a.requestEmulation(it)
30 | }
31 | }
32 | }.join()
33 | assertThat(c).isEqualTo(1)
34 | Unit
35 | }
36 |
37 |
38 | }
39 |
40 | class ApiEmulator(private val task: (i: Int) -> Unit) {
41 | companion object {
42 | // val lock = Mutex()
43 | // var error: Throwable? = null
44 | // var count: AtomicInteger = AtomicInteger(0)
45 |
46 | private val lock = AtomicReference(AccountTokenLock())
47 | }
48 |
49 | fun requestEmulation(t: Int) {
50 | try {
51 | lock()
52 | task(t)
53 | } catch (error: Throwable) {
54 | lock.get().errorContainer = error
55 | } finally {
56 | unlock()
57 | }
58 | }
59 |
60 | private fun lock() = runBlocking {
61 | if (!lock.get().lock.tryLock()) {
62 | // println("waiting ${Thread.currentThread().id}")
63 | lock.get().count.incrementAndGet()
64 | lock.get().lock.lock()
65 | lock.get().errorContainer?.let {
66 | throw it
67 | }
68 | } else {
69 | lock.get().count.incrementAndGet()
70 | }
71 | }
72 |
73 | private fun unlock() {
74 | if (lock.get().count.decrementAndGet() <= 0) {
75 | lock.get().errorContainer = null
76 | }
77 | println(lock.get().count)
78 | lock.get().lock.unlock()
79 | }
80 | }
81 |
82 | internal data class AccountTokenLock(
83 | val lock: Mutex = Mutex(),
84 | var errorContainer: Throwable? = null,
85 | val count: AtomicInteger = AtomicInteger(0)
86 | )
87 |
--------------------------------------------------------------------------------
/demo-android/src/main/java/com/andretietz/retroauth/demo/screen/main/RepositoryAdapter.kt:
--------------------------------------------------------------------------------
1 | package com.andretietz.retroauth.demo.screen.main
2 |
3 | import android.view.LayoutInflater
4 | import android.view.ViewGroup
5 | import androidx.recyclerview.widget.DiffUtil
6 | import androidx.recyclerview.widget.RecyclerView
7 | import com.andretietz.retroauth.demo.R
8 | import com.andretietz.retroauth.demo.api.GithubApi
9 | import com.andretietz.retroauth.demo.databinding.ListitemRepositoryBinding
10 |
11 | class RepositoryAdapter : RecyclerView.Adapter() {
12 |
13 |
14 | private val repositories: MutableList = mutableListOf()
15 |
16 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = RepositoryViewHolder(
17 | ListitemRepositoryBinding
18 | .bind(
19 | LayoutInflater.from(parent.context)
20 | .inflate(R.layout.listitem_repository, parent, false)
21 | )
22 | )
23 |
24 | override fun onBindViewHolder(holder: RepositoryViewHolder, position: Int) =
25 | holder.bind(repositories[position])
26 |
27 | override fun getItemCount() = repositories.size
28 |
29 | fun update(items: List) {
30 | repositories.clear()
31 | repositories.addAll(items)
32 | DiffUtil
33 | .calculateDiff(RepositoryDiff(repositories, items), true)
34 | .dispatchUpdatesTo(this)
35 | notifyDataSetChanged()
36 | }
37 |
38 | class RepositoryViewHolder(private val binding: ListitemRepositoryBinding) :
39 | RecyclerView.ViewHolder(binding.root) {
40 | fun bind(repository: GithubApi.Repository) {
41 | binding.textRepositoryName.text = repository.name
42 | binding.imagePrivate.setImageResource(
43 | if (repository.private) R.drawable.ic_baseline_lock_24
44 | else R.drawable.ic_baseline_lock_open_24
45 | )
46 | }
47 | }
48 |
49 | class RepositoryDiff(
50 | private val old: List,
51 | private val new: List
52 | ) : DiffUtil.Callback() {
53 |
54 | override fun getOldListSize() = old.size
55 |
56 | override fun getNewListSize() = new.size
57 |
58 | override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int) =
59 | old[oldItemPosition].id == new[newItemPosition].id
60 |
61 | override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int) =
62 | old[oldItemPosition] == new[newItemPosition]
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/retroauth/src/main/java/com/andretietz/retroauth/OwnerStorage.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (c) 2016 Andre Tietz
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.andretietz.retroauth
18 |
19 | /**
20 | * Since every credential belongs to a specific user, this users have to be managed.
21 | */
22 | interface OwnerStorage {
23 |
24 | /**
25 | * Creates an [OWNER] for a specific [credentialType].
26 | * So open a login and let the user login.
27 | *
28 | * @param credentialType Type of credential you want to open the login for.
29 | *
30 | * @return [OWNER] which was created or null if canceled
31 | */
32 | fun createOwner(
33 | credentialType: String
34 | ): OWNER?
35 |
36 | /**
37 | * Returns the owner if exists
38 | *
39 | * @param ownerName name of the owner you want to receive.
40 | *
41 | * @return [OWNER] if the owner exists on the system. If not, return `null`.
42 | */
43 | fun getOwner(ownerName: String): OWNER?
44 |
45 | /**
46 | *
47 | * @return [OWNER] that is currently active (important for multi user systems i.e. there could be
48 | * multiple users logged in, but there's only one active). If there's no user currently
49 | * active return `null`
50 | */
51 | fun getActiveOwner(): OWNER?
52 |
53 | /**
54 | *
55 | * @return a list of [OWNER]s of the given type
56 | */
57 | fun getOwners(): List
58 |
59 | /**
60 | * Switches the active owner. If the [owner] is `null`, it resets the
61 | * active owner. So there won't be an active user.
62 | *
63 | * @param owner to which to switch
64 | */
65 | fun switchActiveOwner(owner: OWNER? = null)
66 |
67 | /**
68 | * Removes the given owner from the system.
69 | *
70 | * @param owner the owner to remove.
71 | *
72 | * @return `true` when successfully removed, `false` otherwise.
73 | */
74 | fun removeOwner(owner: OWNER): Boolean
75 |
76 | }
77 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release build
2 |
3 | on:
4 | push:
5 | tags:
6 | - '*.*.*'
7 |
8 | jobs:
9 | build:
10 | name: Release Build
11 | runs-on: ubuntu-latest
12 |
13 | steps:
14 | - name: Checkout
15 | uses: actions/checkout@v2
16 |
17 | - name: Gradle Wrapper Validation
18 | uses: gradle/wrapper-validation-action@v1
19 |
20 | - name: Install JDK
21 | uses: actions/setup-java@v1
22 | with:
23 | java-version: 11
24 |
25 | - name: Version Check
26 | run: |
27 | version=$(grep "VERSION_NAME" gradle.properties | cut -d'=' -f2 )
28 | if [[ $version != *"-SNAPSHOT"* ]]; then
29 | echo "Version string MUST contain \"-SNAPSHOT\"!"
30 | exit 1;
31 | fi
32 | version=$(grep "VERSION_NAME" "./gradle.properties"|cut -d'=' -f2| sed 's/-SNAPSHOT//g')
33 | sed -i'' "s/VERSION_NAME=.*-SNAPSHOT/VERSION_NAME=$version/g" gradle.properties
34 | echo "Building Release Version: $version"
35 |
36 | - name: Build, upload and release Base Version
37 | run: |
38 | ./gradlew retroauth:uploadArchives --stacktrace --no-daemon --no-parallel --warning-mode all
39 | ./gradlew closeAndReleaseRepository --no-daemon --no-parallel --warning-mode all
40 | env:
41 | ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.SONATYPE_NEXUS_USERNAME }}
42 | ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.SONATYPE_NEXUS_PASSWORD }}
43 | ORG_GRADLE_PROJECT_signingKey: ${{ secrets.GPG_SIGNING_KEY }}
44 | ORG_GRADLE_PROJECT_signingKeyId: ${{ secrets.GPG_SIGNING_KEY_ID }}
45 | ORG_GRADLE_PROJECT_signingPassword: ${{ secrets.GPG_SIGNING_KEY_SECRET }}
46 |
47 | - name: Build, upload and release Android Version
48 | run: |
49 | ./gradlew android-accountmanager:uploadArchives --stacktrace --no-daemon --no-parallel --warning-mode all
50 | ./gradlew closeAndReleaseRepository --no-daemon --no-parallel --warning-mode all
51 | env:
52 | ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.SONATYPE_NEXUS_USERNAME }}
53 | ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.SONATYPE_NEXUS_PASSWORD }}
54 | ORG_GRADLE_PROJECT_signingKey: ${{ secrets.GPG_SIGNING_KEY }}
55 | ORG_GRADLE_PROJECT_signingKeyId: ${{ secrets.GPG_SIGNING_KEY_ID }}
56 | ORG_GRADLE_PROJECT_signingPassword: ${{ secrets.GPG_SIGNING_KEY_SECRET }}
57 |
58 |
--------------------------------------------------------------------------------
/demo-android/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | id("com.android.application")
3 | kotlin("android")
4 | kotlin("kapt")
5 | id("dagger.hilt.android.plugin")
6 | }
7 | android {
8 | compileSdk = 31
9 | defaultConfig {
10 | applicationId = "com.andretietz.retroauth.demo"
11 | minSdk = 23
12 | versionCode = 1
13 | versionName = "1.0.0"
14 | }
15 | buildTypes {
16 | getByName("release") {
17 | isMinifyEnabled = true
18 | }
19 | }
20 |
21 | compileOptions {
22 | sourceCompatibility = JavaVersion.VERSION_1_8
23 | targetCompatibility = JavaVersion.VERSION_1_8
24 | }
25 |
26 | buildFeatures {
27 | viewBinding = true
28 | }
29 |
30 | kotlinOptions {
31 | jvmTarget = "1.8"
32 | }
33 | }
34 |
35 | dependencies {
36 | api(project(":android-accountmanager"))
37 | implementation(project(":retroauth"))
38 | implementation(Dependencies.android.appcompat)
39 | implementation(Dependencies.coroutines)
40 | implementation(Dependencies.android.timber)
41 | implementation(Dependencies.android.fragment)
42 | implementation(Dependencies.android.constraintLayout)
43 | implementation(Dependencies.android.swipeRefresh)
44 | implementation(Dependencies.android.recyclerView)
45 | implementation(Dependencies.android.lifecycle.runtime)
46 | implementation(Dependencies.android.lifecycle.viewmodel)
47 | implementation(Dependencies.android.lifecycle.livedata)
48 | implementation(Dependencies.okhttp.okhttp)
49 | implementation(Dependencies.okhttp.loggingInterceptor)
50 | implementation(Dependencies.retrofit.moshiConverter)
51 | implementation(Dependencies.moshi.moshi) {
52 | exclude("org.jetbrains.kotlin", "kotlin-reflect")
53 | }
54 | kapt(Dependencies.moshi.moshiCodegen)
55 |
56 | implementation(Dependencies.android.hilt)
57 | kapt(Dependencies.android.hiltCodegen)
58 |
59 | // handling oauth login types. In this case: github
60 | implementation(Dependencies.scribe.scribe)
61 | implementation(Dependencies.scribe.okhttp)
62 |
63 | // implementation("androidx.compose.runtime:runtime:1.0.0-rc02")
64 | // implementation("androidx.compose.ui:ui:1.0.0-rc02")
65 | // implementation("androidx.compose.foundation:foundation-layout:1.0.0-rc02")
66 | // implementation("androidx.compose.material:material:1.0.0-rc02")
67 | // implementation("androidx.compose.material:material-icons-extended:1.0.0-rc02")
68 | // implementation("androidx.compose.foundation:foundation:1.0.0-rc02")
69 | // implementation("androidx.compose.animation:animation:1.0.0-rc02")
70 | // implementation("androidx.compose.ui:ui-tooling:1.0.0-rc02")
71 | // implementation("androidx.activity:activity-compose:1.3.0-rc02")
72 | }
73 |
--------------------------------------------------------------------------------
/gradlew.bat:
--------------------------------------------------------------------------------
1 | @if "%DEBUG%" == "" @echo off
2 | @rem ##########################################################################
3 | @rem
4 | @rem Gradle startup script for Windows
5 | @rem
6 | @rem ##########################################################################
7 |
8 | @rem Set local scope for the variables with windows NT shell
9 | if "%OS%"=="Windows_NT" setlocal
10 |
11 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
12 | set DEFAULT_JVM_OPTS=
13 |
14 | set DIRNAME=%~dp0
15 | if "%DIRNAME%" == "" set DIRNAME=.
16 | set APP_BASE_NAME=%~n0
17 | set APP_HOME=%DIRNAME%
18 |
19 | @rem Find java.exe
20 | if defined JAVA_HOME goto findJavaFromJavaHome
21 |
22 | set JAVA_EXE=java.exe
23 | %JAVA_EXE% -version >NUL 2>&1
24 | if "%ERRORLEVEL%" == "0" goto init
25 |
26 | echo.
27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
28 | echo.
29 | echo Please set the JAVA_HOME variable in your environment to match the
30 | echo location of your Java installation.
31 |
32 | goto fail
33 |
34 | :findJavaFromJavaHome
35 | set JAVA_HOME=%JAVA_HOME:"=%
36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
37 |
38 | if exist "%JAVA_EXE%" goto init
39 |
40 | echo.
41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
42 | echo.
43 | echo Please set the JAVA_HOME variable in your environment to match the
44 | echo location of your Java installation.
45 |
46 | goto fail
47 |
48 | :init
49 | @rem Get command-line arguments, handling Windowz variants
50 |
51 | if not "%OS%" == "Windows_NT" goto win9xME_args
52 | if "%@eval[2+2]" == "4" goto 4NT_args
53 |
54 | :win9xME_args
55 | @rem Slurp the command line arguments.
56 | set CMD_LINE_ARGS=
57 | set _SKIP=2
58 |
59 | :win9xME_args_slurp
60 | if "x%~1" == "x" goto execute
61 |
62 | set CMD_LINE_ARGS=%*
63 | goto execute
64 |
65 | :4NT_args
66 | @rem Get arguments from the 4NT Shell from JP Software
67 | set CMD_LINE_ARGS=%$
68 |
69 | :execute
70 | @rem Setup the command line
71 |
72 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
73 |
74 | @rem Execute Gradle
75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
76 |
77 | :end
78 | @rem End local scope for the variables with windows NT shell
79 | if "%ERRORLEVEL%"=="0" goto mainEnd
80 |
81 | :fail
82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
83 | rem the _cmd.exe /c_ return code!
84 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
85 | exit /b 1
86 |
87 | :mainEnd
88 | if "%OS%"=="Windows_NT" endlocal
89 |
90 | :omega
91 |
--------------------------------------------------------------------------------
/retroauth/src/main/java/com/andretietz/retroauth/Authenticator.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (c) 2016 Andre Tietz
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.andretietz.retroauth
18 |
19 | import okhttp3.Request
20 | import okhttp3.Response
21 | import java.net.HttpURLConnection
22 |
23 | /**
24 | * The Authenticator interface is a very specific provider endpoint dependent implementation,
25 | * to authenticate your request and defines when or if to retry.
26 | * This needs to be an abstract class in order to be java 7 compatible (android).
27 | */
28 | abstract class Authenticator {
29 |
30 | /**
31 | * @param credentialType type of the credential reached in from the credentialType
32 | * Annotation of the request.
33 | *
34 | * @return type of the credential
35 | */
36 | abstract fun getCredentialType(credentialType: Int = 0): String
37 |
38 | /**
39 | * Authenticates a [Request].
40 | *
41 | * @param request request to authenticate
42 | * @param credential Token to authenticate
43 | * @return a modified version of the incoming request, which is authenticated
44 | */
45 | abstract fun authenticateRequest(request: Request, credential: Credentials): Request
46 |
47 | /**
48 | * Checks if the credential needs to be refreshed or not.
49 | *
50 | * @param count value contains how many times this request has been executed already
51 | * @param response response to check what the result was
52 | * @return {@code true} if a credential refresh is required, {@code false} if not
53 | */
54 | open fun refreshRequired(count: Int, response: Response): Boolean =
55 | response.code == HttpURLConnection.HTTP_UNAUTHORIZED && count <= 1
56 |
57 | /**
58 | * This method will be called when [isCredentialValid] returned false or [refreshRequired] returned true.
59 | *
60 | * @param credential of the local [CredentialStorage]
61 | */
62 | @Suppress("UNUSED_PARAMETER")
63 | open fun refreshCredentials(
64 | owner: OWNER,
65 | credentialType: String,
66 | credential: Credentials
67 | ): Credentials? = null
68 |
69 | /**
70 | * This method is called on each authenticated request, to make sure the current credential
71 | * is still valid. With this method, you can refresh your token before the first request is
72 | * called.
73 | *
74 | * @param credential The current credential
75 | */
76 | @Suppress("UNUSED_PARAMETER")
77 | open fun isCredentialValid(credential: Credentials): Boolean = true
78 | }
79 |
--------------------------------------------------------------------------------
/demo-android/src/main/java/com/andretietz/retroauth/demo/auth/LoginActivity.kt:
--------------------------------------------------------------------------------
1 | package com.andretietz.retroauth.demo.auth
2 |
3 | import android.annotation.SuppressLint
4 | import android.os.Bundle
5 | import android.webkit.CookieManager
6 | import android.webkit.WebView
7 | import android.webkit.WebViewClient
8 | import androidx.lifecycle.lifecycleScope
9 | import com.andretietz.retroauth.AuthenticationActivity
10 | import com.andretietz.retroauth.Credentials
11 | import com.andretietz.retroauth.demo.databinding.ActivityLoginBinding
12 | import com.github.scribejava.core.oauth.OAuth20Service
13 | import com.squareup.moshi.JsonClass
14 | import dagger.hilt.android.AndroidEntryPoint
15 | import kotlinx.coroutines.Dispatchers
16 | import kotlinx.coroutines.launch
17 | import kotlinx.coroutines.withContext
18 | import retrofit2.http.GET
19 | import retrofit2.http.Header
20 | import javax.inject.Inject
21 |
22 | @AndroidEntryPoint
23 | class LoginActivity : AuthenticationActivity() {
24 |
25 | @Inject
26 | lateinit var helper: OAuth20Service
27 |
28 | @Inject
29 | lateinit var api: SignInApi
30 |
31 | @SuppressLint("SetJavaScriptEnabled")
32 | override fun onCreate(savedInstanceState: Bundle?) {
33 | super.onCreate(savedInstanceState)
34 | CookieManager.getInstance().removeAllCookies { }
35 | val views = ActivityLoginBinding.inflate(layoutInflater)
36 | setContentView(views.root)
37 | views.webView.loadUrl(helper.authorizationUrl)
38 | views.webView.settings.javaScriptEnabled = true
39 | views.webView.webViewClient = object : WebViewClient() {
40 | override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean {
41 | val authorization = helper.extractAuthorization(url)
42 | val code = authorization.code
43 | if (code == null) {
44 | view.loadUrl(url)
45 | } else {
46 | lifecycleScope.launch(Dispatchers.IO) {
47 | val token = helper.getAccessToken(code)
48 | val userInfo = api.getUser("Bearer ${token.accessToken}")
49 | withContext(Dispatchers.Main) {
50 | val account = createOrGetAccount(userInfo.login)
51 | storeCredentials(
52 | account,
53 | GithubAuthenticator.createTokenType(application),
54 | Credentials(token.accessToken)
55 | )
56 | // storeUserData(account, "email", userInfo.email)
57 | finalizeAuthentication(account)
58 | }
59 | }
60 | }
61 | return true
62 | }
63 | }
64 | }
65 |
66 | interface SignInApi {
67 | /**
68 | * We call this method right after authentication in order to get user information
69 | * to store within the account. At this point of time, the account and it's token isn't stored
70 | * yet, why we cannot use the annotation.
71 | *
72 | * https://docs.github.com/en/rest/reference/users#get-the-authenticated-user
73 | */
74 | @GET("user")
75 | suspend fun getUser(@Header("Authorization") token: String): User
76 |
77 | @JsonClass(generateAdapter = true)
78 | data class User(
79 | val login: String
80 | )
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/sqlite/src/main/kotlin/com/andretietz/retroauth/SQLiteCredentialStore.kt:
--------------------------------------------------------------------------------
1 | package com.andretietz.retroauth
2 |
3 | import com.andretietz.retroauth.sqlite.CredentialTable
4 | import com.andretietz.retroauth.sqlite.DataTable
5 | import com.andretietz.retroauth.sqlite.DatabaseCredential
6 | import com.andretietz.retroauth.sqlite.DatabaseData
7 | import com.andretietz.retroauth.sqlite.DatabaseUser
8 | import com.andretietz.retroauth.sqlite.data.Account
9 | import org.jetbrains.exposed.sql.Database
10 | import org.jetbrains.exposed.sql.and
11 | import org.jetbrains.exposed.sql.transactions.transaction
12 |
13 | class SQLiteCredentialStore(
14 | private val database: Database
15 | ) : CredentialStorage {
16 |
17 | override fun getCredentials(owner: Account, credentialType: String): Credentials? {
18 | return transaction(database) {
19 | DatabaseCredential.find {
20 | CredentialTable.user eq owner.id and (CredentialTable.type eq credentialType)
21 | }.toList()
22 | .firstOrNull()
23 | ?.let { credential ->
24 | Credentials(
25 | credential.value,
26 | DatabaseData.find { DataTable.user eq owner.id and (DataTable.credential eq credential.id) }
27 | .associate { it.key to it.value }
28 | )
29 | }
30 | }
31 | }
32 |
33 |
34 | override fun removeCredentials(owner: Account, credentialType: String) {
35 | transaction(database) {
36 | DatabaseUser.findById(owner.id)
37 | ?.let { user ->
38 | DatabaseCredential.find {
39 | CredentialTable.user eq owner.id and (CredentialTable.type eq credentialType)
40 | }.toList().firstOrNull()?.let { credential ->
41 | DatabaseData
42 | .find { DataTable.user eq user.id and (DataTable.credential eq credential.id) }
43 | .forEach { it.delete() }
44 | DatabaseCredential
45 | .find { CredentialTable.user eq user.id and (CredentialTable.type eq credentialType) }
46 | .firstOrNull()?.delete()
47 | }
48 | }
49 | }
50 | }
51 |
52 | override fun storeCredentials(owner: Account, credentialType: String, credentials: Credentials) {
53 | transaction(database) {
54 | DatabaseUser.findById(owner.id)
55 | ?.let { user ->
56 | val credential = DatabaseCredential.find {
57 | CredentialTable.user eq owner.id and (CredentialTable.type eq credentialType)
58 | }.toList().firstOrNull()
59 | val c = credential?.also {
60 | it.value = credentials.token
61 | } ?: DatabaseCredential.new {
62 | this.type = credentialType
63 | this.user = user
64 | this.value = credentials.token
65 | }
66 | DatabaseData
67 | .find { DataTable.user eq owner.id and (DataTable.credential eq c.id) }
68 | .toList()
69 | credentials.data?.map { (key, value) ->
70 |
71 | // TODO: see if we need to override things
72 |
73 |
74 | DatabaseData.new {
75 | this.user = user
76 | this.credential = c
77 | this.key = key
78 | this.value = value
79 | }
80 | }
81 | }
82 | }
83 | }
84 | }
85 |
--------------------------------------------------------------------------------
/retroauth/README.md:
--------------------------------------------------------------------------------
1 | # A simple way of calling authenticated requests using retrofit
2 | [](https://github.com/andretietz/retroauth/actions?query=workflow%3A%22Snapshot+build%22)
3 | ## Dependencies
4 | * [Retrofit](https://github.com/square/retrofit) 2.5.0
5 |
6 | ## Example:
7 | This is how you would create an authenticated call using retroauth. Just create
8 | the interface as you're used to and annotate your authenticated methods as such.
9 | using the ```@Authenticated``` annotation.
10 | ``` kotlin
11 | interface SomeService {
12 | @Authenticated
13 | @GET("/some/path")
14 | Call someAuthenticatedRequest()
15 | }
16 |
17 | ```
18 |
19 | If you're an Android Developer feel free to go directly to the [android implementation](android-accountmanager/).
20 | ## How to use it?
21 |
22 | Add it as dependency:
23 | ```groovy
24 | implementation 'com.andretietz.retroauth:retroauth:x.y.z'
25 | ```
26 |
27 | ## The API
28 |
29 | This library is made for a system that CAN have multiple users.
30 | These users CAN have multiple different ```TOKEN```s of a specific ```TOKEN_TYPE```.
31 | A User is an ```OWNER``` of a ```TOKEN```, so within the library they're called ```OWNER```.
32 | You can also have different ```OWNER_TYPE```s.
33 |
34 | * ```OWNER_TYPE``` -> contains one or more:
35 | * ```OWNER```s -> owns one or more:
36 | * ```TOKEN_TYPE```s -> is bound to exactly one ```TOKEN```
37 |
38 | In most of the cases you probably need only one ```OWNER_TYPE``` which contains one ```OWNER```, which owns one ```TOKEN``` of a specific ```TOKEN_TYPE```.
39 | Which is totally fine.
40 |
41 |
42 | The API provides 3 interaces and an abstract class. All of the
43 | ### The interfaces
44 | * [OwnerManager](src/main/java/com/andretietz/retroauth/OwnerManager.kt): In order to handle one or more Owners on a system you need to provide some basic functionalities to handle this Owners.
45 | * [TokenStorage](src/main/java/com/andretietz/retroauth/TokenStorage.kt): So that
46 | * [MethodCache](src/main/java/com/andretietz/retroauth/MethodCache.kt):
47 | This is an interface optionally to implement. If you don't, you can use it's default implementation, the ```DefaultMethodCache```.
48 | * [Authenticator](src/main/java/com/andretietz/retroauth/Authenticator.kt): This is an abstract class and it's supposed to be an abstract class to the backend you're authenticating against.
49 |
50 | ## Pull requests are welcome
51 | Since I am the only one working on that, I would like to know your opinion and/or your suggestions.
52 | Please feel free to create Pull-Requests!u
53 |
54 | ## LICENSE
55 | ```
56 | Copyrights 2018 André Tietz
57 |
58 | Licensed under the Apache License, Version 2.0 (the "License");
59 | you may not use this file except in compliance with the License.
60 | You may obtain a copy of the License at
61 |
62 |
63 |
64 | Unless required by applicable law or agreed to in writing, software
65 | distributed under the License is distributed on an "AS IS" BASIS,
66 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
67 | See the License for the specific language governing permissions and
68 | limitations under the License.
69 | ```
70 |
--------------------------------------------------------------------------------
/demo-android/src/main/java/com/andretietz/retroauth/demo/screen/main/MainViewModel.kt:
--------------------------------------------------------------------------------
1 | package com.andretietz.retroauth.demo.screen.main
2 |
3 | import androidx.annotation.WorkerThread
4 | import androidx.lifecycle.ViewModel
5 | import com.andretietz.retroauth.AndroidAccountManagerCredentialStorage
6 | import com.andretietz.retroauth.AndroidAccountManagerOwnerStorage
7 | import com.andretietz.retroauth.AuthenticationCanceledException
8 | import com.andretietz.retroauth.AuthenticationRequiredException
9 | import com.andretietz.retroauth.Credentials
10 | import com.andretietz.retroauth.demo.api.GithubApi
11 | import com.andretietz.retroauth.demo.auth.GithubAuthenticator
12 | import dagger.hilt.android.lifecycle.HiltViewModel
13 | import kotlinx.coroutines.CoroutineName
14 | import kotlinx.coroutines.CoroutineScope
15 | import kotlinx.coroutines.Dispatchers
16 | import kotlinx.coroutines.flow.MutableStateFlow
17 | import kotlinx.coroutines.launch
18 | import timber.log.Timber
19 | import javax.inject.Inject
20 |
21 | @HiltViewModel
22 | class MainViewModel @Inject constructor(
23 | private val api: GithubApi,
24 | private val ownerStorage: AndroidAccountManagerOwnerStorage,
25 | private val credentialStorage: AndroidAccountManagerCredentialStorage,
26 | private val authenticator: GithubAuthenticator
27 | ) : ViewModel() {
28 |
29 | private val _state = MutableStateFlow(MainViewState.InitialState)
30 |
31 | private val scope = CoroutineScope(Dispatchers.Default + CoroutineName("ViewModelScope"))
32 |
33 | val state = _state
34 |
35 | init {
36 | scope.launch {
37 | loadRepositories()
38 | }
39 | }
40 |
41 | fun addAccount() {
42 | scope.launch {
43 | val account = ownerStorage.createOwner(
44 | authenticator.getCredentialType())
45 | if (account == null) {
46 | _state.value = MainViewState.Error(AuthenticationCanceledException())
47 | } else {
48 | Timber.d("Logged in: $account")
49 | _state.value = MainViewState.InitialState
50 | _state.value = MainViewState.LoginSuccess(account)
51 | }
52 | }
53 | }
54 |
55 | fun loadRepositories() = scope.launch(Dispatchers.IO) {
56 | try {
57 | _state.value = MainViewState.RepositoryUpdate(api.getRepositories())
58 | } catch (error: AuthenticationRequiredException) {
59 | _state.value = MainViewState.InitialState
60 | }
61 | }
62 |
63 | fun invalidateTokens() {
64 | ownerStorage.getActiveOwner()?.let { account ->
65 | val credential = credentialStorage.getCredentials(
66 | account,
67 | authenticator.getCredentialType())
68 | if (credential == null) {
69 | _state.value = MainViewState.Error(AuthenticationCanceledException())
70 | } else {
71 | credentialStorage.storeCredentials(
72 | account,
73 | authenticator.getCredentialType(),
74 | Credentials("some-invalid-token", credential.data)
75 | )
76 | }
77 | }
78 | }
79 |
80 | fun logout() = scope.launch(Dispatchers.Default) {
81 | ownerStorage.getActiveOwner()?.let { account ->
82 | if (ownerStorage.removeOwner(account)) {
83 | _state.value = MainViewState.LogoutSuccess
84 | } else {
85 | _state.value = MainViewState.Error(UnknownError())
86 | }
87 | }
88 | }
89 |
90 | fun getCurrentAccount() = ownerStorage.getActiveOwner()
91 |
92 | }
93 |
--------------------------------------------------------------------------------
/android-accountmanager/src/main/java/com/andretietz/retroauth/AndroidAccountManagerOwnerStorage.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (c) 2016 Andre Tietz
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.andretietz.retroauth
18 |
19 | import android.accounts.Account
20 | import android.accounts.AccountManager
21 | import android.app.Application
22 | import android.content.Context
23 | import android.os.Build
24 |
25 | /**
26 | * This is the Android implementation of an [OwnerStorage]. It does all the Android [Account]
27 | * handling using tha Android [AccountManager].
28 | */
29 | @Suppress("unused")
30 | class AndroidAccountManagerOwnerStorage constructor(
31 | private val application: Application,
32 | private val ownerType: String
33 | ) : OwnerStorage {
34 |
35 | companion object {
36 | private const val RETROAUTH_ACCOUNT_NAME_KEY = "com.andretietz.retroauth.ACTIVE_ACCOUNT"
37 | }
38 |
39 | private val activityManager by lazy { ActivityManager[application] }
40 | private val accountManager by lazy { AccountManager.get(application) }
41 |
42 | @Suppress("BlockingMethodInNonBlockingContext")
43 | override fun createOwner(credentialType: String): Account? {
44 | val bundle = accountManager.addAccount(
45 | ownerType,
46 | credentialType,
47 | null,
48 | null,
49 | activityManager.activity,
50 | null,
51 | null).result
52 | return bundle.getString(AccountManager.KEY_ACCOUNT_NAME)?.let {
53 | Account(bundle.getString(AccountManager.KEY_ACCOUNT_NAME),
54 | bundle.getString(AccountManager.KEY_ACCOUNT_TYPE))
55 | }
56 | }
57 |
58 |
59 | override fun getOwner(ownerName: String): Account? {
60 | return accountManager.getAccountsByType(ownerType)
61 | .firstOrNull { ownerName == it.name }
62 | }
63 |
64 | override fun getActiveOwner(): Account? {
65 | return application.getSharedPreferences(ownerType, Context.MODE_PRIVATE)
66 | .getString(RETROAUTH_ACCOUNT_NAME_KEY, null)?.let { accountName ->
67 | getOwner(accountName)
68 | }
69 | }
70 |
71 | override fun getOwners(): List = accountManager.accounts.toList()
72 | .filter { it.type == ownerType }
73 |
74 | override fun switchActiveOwner(owner: Account?) {
75 | val preferences = application.getSharedPreferences(ownerType, Context.MODE_PRIVATE)
76 | if (owner == null) {
77 | preferences.edit().remove(RETROAUTH_ACCOUNT_NAME_KEY).apply()
78 | } else {
79 | preferences.edit().putString(RETROAUTH_ACCOUNT_NAME_KEY, owner.name).apply()
80 | }
81 | }
82 |
83 | override fun removeOwner(owner: Account): Boolean {
84 | val success = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) {
85 | accountManager.removeAccount(owner, null, null, null).result
86 | .getBoolean(AccountManager.KEY_BOOLEAN_RESULT)
87 | } else {
88 | @Suppress("DEPRECATION")
89 | accountManager.removeAccount(owner, null, null).result
90 | }
91 | if (success) switchActiveOwner(owner)
92 | return success
93 | }
94 | }
95 |
--------------------------------------------------------------------------------
/android-accountmanager/src/main/java/com/andretietz/retroauth/ActivityManager.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (c) 2016 Andre Tietz
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.andretietz.retroauth
18 |
19 | import android.app.Activity
20 | import android.app.Application
21 | import android.app.Application.ActivityLifecycleCallbacks
22 | import android.os.Bundle
23 |
24 | /**
25 | * The [ActivityManager] provides an application [android.content.Context] as well as an [Activity] if
26 | * this was not stopped already. It registers [ActivityLifecycleCallbacks] to be able to know if there's an active
27 | * [Activity] or not. The [Activity] is required in case the user calls an
28 | * [com.andretietz.retroauth.Authenticated] request and there are not Credentials provided, to be able to open the
29 | * [Activity] for login, using the
30 | * [android.accounts.AccountManager.getAuthToken]. If you don't provide an [Activity] there, the
31 | * login screen wont open. So in case you're calling an [com.andretietz.retroauth.Authenticated] request from a
32 | * [android.app.Service] there will be no Login if required.
33 | */
34 | internal class ActivityManager private constructor(application: Application) {
35 | private val handler: LifecycleHandler
36 |
37 | /**
38 | * @return an [Activity] if there's one available.
39 | */
40 | val activity: Activity? get() = handler.current
41 |
42 | init {
43 | handler = LifecycleHandler()
44 | application.registerActivityLifecycleCallbacks(handler)
45 | }
46 |
47 | /**
48 | * An implementation of [ActivityLifecycleCallbacks] which stores a reference to the [Activity] as long as
49 | * it is not stopped. If the [Activity] is stopped, the reference will be removed.
50 | */
51 | private class LifecycleHandler : ActivityLifecycleCallbacks {
52 | private val activityStack = WeakActivityStack()
53 |
54 | val current: Activity? get() = activityStack.peek()
55 |
56 | override fun onActivityResumed(activity: Activity) = Unit
57 |
58 | override fun onActivityPaused(activity: Activity) = Unit
59 |
60 | override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) = Unit
61 |
62 | override fun onActivityStarted(activity: Activity) = activityStack.push(activity)
63 |
64 | override fun onActivityStopped(activity: Activity) = activityStack.remove(activity)
65 |
66 | override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit
67 |
68 | override fun onActivityDestroyed(activity: Activity) = Unit
69 | }
70 |
71 | companion object {
72 | @Volatile
73 | private var instance: ActivityManager? = null
74 |
75 | /**
76 | * @param application some [Activity] to be able to create the instance
77 | * @return a singleton instance of the [ActivityManager].
78 | */
79 | @JvmStatic
80 | operator fun get(application: Application): ActivityManager {
81 | instance?.let { return it }
82 | return synchronized(this) {
83 | instance?.let { return it }
84 | ActivityManager(application).apply {
85 | instance = this
86 | }
87 | }
88 | }
89 | }
90 | }
91 |
--------------------------------------------------------------------------------
/demo-android/src/main/java/com/andretietz/retroauth/demo/di/ApiModule.kt:
--------------------------------------------------------------------------------
1 | package com.andretietz.retroauth.demo.di
2 |
3 | import android.app.Application
4 | import com.andretietz.retroauth.AndroidAccountManagerCredentialStorage
5 | import com.andretietz.retroauth.AndroidAccountManagerOwnerStorage
6 | import com.andretietz.retroauth.androidAuthentication
7 | import com.andretietz.retroauth.demo.R
8 | import com.andretietz.retroauth.demo.api.GithubApi
9 | import com.andretietz.retroauth.demo.auth.GithubAuthenticator
10 | import com.andretietz.retroauth.demo.auth.LoginActivity
11 | import com.github.scribejava.apis.GitHubApi
12 | import com.github.scribejava.core.builder.ServiceBuilder
13 | import com.github.scribejava.core.oauth.OAuth20Service
14 | import com.github.scribejava.httpclient.okhttp.OkHttpHttpClient
15 | import dagger.Module
16 | import dagger.Provides
17 | import dagger.hilt.InstallIn
18 | import dagger.hilt.components.SingletonComponent
19 | import okhttp3.Interceptor
20 | import okhttp3.OkHttpClient
21 | import okhttp3.logging.HttpLoggingInterceptor
22 | import retrofit2.Retrofit
23 | import retrofit2.converter.moshi.MoshiConverterFactory
24 | import javax.inject.Singleton
25 |
26 | @Module
27 | @InstallIn(SingletonComponent::class)
28 | object ApiModule {
29 |
30 | /**
31 | * provides the scribejava object to authenticate using github.
32 | */
33 | @Singleton
34 | @Provides
35 | fun provideOauthService(): OAuth20Service {
36 | return ServiceBuilder(GithubAuthenticator.CLIENT_ID)
37 | .apiSecret(GithubAuthenticator.CLIENT_SECRET)
38 | .callback(GithubAuthenticator.CLIENT_CALLBACK)
39 | .httpClient(OkHttpHttpClient())
40 | .defaultScope("repo user:email")
41 | .build(GitHubApi.instance())
42 | }
43 |
44 | @Singleton
45 | @Provides
46 | fun provideRetrofit(
47 | application: Application,
48 | authenticator: GithubAuthenticator
49 | ): Retrofit {
50 | /**
51 | * Optional: create your own OkHttpClient
52 | */
53 | val httpClient = OkHttpClient.Builder()
54 | .addNetworkInterceptor(HttpLoggingInterceptor()
55 | .also { it.level = HttpLoggingInterceptor.Level.BODY })
56 | .addInterceptor { chain: Interceptor.Chain ->
57 | val request = chain.request().newBuilder()
58 | .addHeader("Accept", "application/vnd.github.v3+json")
59 | .build()
60 | chain.proceed(request)
61 | }
62 | .build()
63 |
64 | /**
65 | * Create your Retrofit Object using the [Retrofit.androidAuthentication]
66 | */
67 | return Retrofit.Builder()
68 | .baseUrl("https://api.github.com")
69 | .client(httpClient)
70 | .addConverterFactory(MoshiConverterFactory.create())
71 | .build()
72 | .androidAuthentication(application, authenticator, application.getString(R.string.authentication_ACCOUNT))
73 | }
74 |
75 | @Singleton
76 | @Provides
77 | fun providesOwnerStorage(application: Application) =
78 | AndroidAccountManagerOwnerStorage(
79 | application,
80 | application.getString(R.string.authentication_ACCOUNT))
81 |
82 | @Singleton
83 | @Provides
84 | fun providesCredentialStorage(application: Application) = AndroidAccountManagerCredentialStorage(application)
85 |
86 | @Singleton
87 | @Provides
88 | fun providesAuthenticator(application: Application): GithubAuthenticator =
89 | GithubAuthenticator(application)
90 |
91 | @Singleton
92 | @Provides
93 | fun providesGithubSignInApi(retrofit: Retrofit): LoginActivity.SignInApi =
94 | retrofit.create(LoginActivity.SignInApi::class.java)
95 |
96 | @Singleton
97 | @Provides
98 | fun providesGithubApi(retrofit: Retrofit): GithubApi =
99 | retrofit.create(GithubApi::class.java)
100 | }
101 |
--------------------------------------------------------------------------------
/android-accountmanager/src/test/java/com/andretietz/retroauth/AccountAuthenticatorTest.kt:
--------------------------------------------------------------------------------
1 | package com.andretietz.retroauth
2 |
3 | import android.accounts.Account
4 | import android.accounts.AccountAuthenticatorResponse
5 | import android.accounts.AccountManager
6 | import android.app.Application
7 | import android.content.Intent
8 | import android.os.Bundle
9 | import androidx.test.core.app.ApplicationProvider
10 | import androidx.test.ext.junit.runners.AndroidJUnit4
11 | import org.junit.Assert.assertEquals
12 | import org.junit.Assert.assertNotNull
13 | import org.junit.Assert.assertNull
14 | import org.junit.Before
15 | import org.junit.Test
16 | import org.junit.runner.RunWith
17 | import org.mockito.Mockito.mock
18 |
19 |
20 | @RunWith(AndroidJUnit4::class)
21 | class AccountAuthenticatorTest {
22 |
23 | private val application = ApplicationProvider.getApplicationContext()
24 |
25 | private val authenticator: AccountAuthenticator =
26 | AccountAuthenticator(application, "some-action") {}
27 |
28 | @Before
29 | fun setup() {
30 | ActivityManager[application]
31 | }
32 |
33 | @Test
34 | fun addAccount() {
35 | val response = mock(AccountAuthenticatorResponse::class.java)
36 | val bundle = authenticator.addAccount(
37 | response, "accountType", "credentialType",
38 | arrayOf(), mock(Bundle::class.java)
39 | )
40 |
41 | assertNotNull(bundle)
42 | val intent = requireNotNull(bundle).getParcelable(AccountManager.KEY_INTENT)
43 | assertNotNull(intent)
44 |
45 | assertEquals(
46 | response,
47 | requireNotNull(intent).getParcelableExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE)
48 | )
49 | assertEquals("accountType", intent.getStringExtra(AccountManager.KEY_ACCOUNT_TYPE))
50 | assertEquals("credentialType", intent.getStringExtra(AccountAuthenticator.KEY_CREDENTIAL_TYPE))
51 | }
52 |
53 | @Test
54 | fun getAuthToken() {
55 | val response = mock(AccountAuthenticatorResponse::class.java)
56 | val account = Account("accountName", "accountType")
57 | val bundle =
58 | authenticator.getAuthToken(response, account, "credentialType", mock(Bundle::class.java))
59 |
60 | assertNotNull(bundle)
61 | val intent = requireNotNull(bundle).getParcelable(AccountManager.KEY_INTENT)
62 | assertNotNull(intent)
63 |
64 | assertEquals(
65 | response,
66 | requireNotNull(intent).getParcelableExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE)
67 | )
68 | assertEquals("accountType", intent.getStringExtra(AccountManager.KEY_ACCOUNT_TYPE))
69 | assertEquals("credentialType", intent.getStringExtra(AccountAuthenticator.KEY_CREDENTIAL_TYPE))
70 | assertEquals("accountName", intent.getStringExtra(AccountManager.KEY_ACCOUNT_NAME))
71 | }
72 |
73 | @Test
74 | fun hasFeatures() {
75 | val bundle = authenticator
76 | .hasFeatures(
77 | mock(AccountAuthenticatorResponse::class.java),
78 | mock(Account::class.java),
79 | arrayOf()
80 | )
81 | assertNull(bundle)
82 | }
83 |
84 | @Test
85 | fun updateCredentials() {
86 | val bundle = authenticator
87 | .updateCredentials(
88 | mock(AccountAuthenticatorResponse::class.java),
89 | mock(Account::class.java),
90 | "credential-type",
91 | mock(Bundle::class.java)
92 | )
93 | assertNull(bundle)
94 | }
95 |
96 | @Test
97 | fun getAuthTokenLabel() {
98 | val label = authenticator.getAuthTokenLabel("credential-type")
99 | assertNull(label)
100 | }
101 |
102 | @Test
103 | fun editProperties() {
104 | val bundle = authenticator
105 | .editProperties(mock(AccountAuthenticatorResponse::class.java), "accountType")
106 | assertNull(bundle)
107 | }
108 |
109 | @Test
110 | fun confirmCredentials() {
111 | val bundle = authenticator
112 | .confirmCredentials(
113 | mock(AccountAuthenticatorResponse::class.java), mock(Account::class.java),
114 | mock(Bundle::class.java)
115 | )
116 | assertNull(bundle)
117 | }
118 | }
119 |
--------------------------------------------------------------------------------
/android-accountmanager/src/main/java/com/andretietz/retroauth/AndroidAccountManagerCredentialStorage.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (c) 2016 Andre Tietz
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.andretietz.retroauth
18 |
19 | import android.accounts.Account
20 | import android.accounts.AccountManager
21 | import android.app.Application
22 | import android.os.Looper
23 | import android.util.Base64
24 | import android.util.Base64.DEFAULT
25 | import java.util.concurrent.Callable
26 | import java.util.concurrent.Executors
27 | import java.util.concurrent.TimeUnit
28 |
29 | /**
30 | * This is the implementation of a [CredentialStorage] in Android using the Android [AccountManager]
31 | */
32 | class AndroidAccountManagerCredentialStorage constructor(
33 | private val application: Application
34 | ) : CredentialStorage {
35 |
36 | private val accountManager by lazy { AccountManager.get(application) }
37 |
38 | private val executor = Executors.newSingleThreadExecutor()
39 |
40 | companion object {
41 | private fun createDataKey(type: String, key: String) = "${type}_$key"
42 | }
43 |
44 | override fun getCredentials(
45 | owner: Account,
46 | credentialType: String
47 | ): Credentials? {
48 | val future = accountManager.getAuthToken(
49 | owner,
50 | credentialType,
51 | null,
52 | ActivityManager[application].activity,
53 | null,
54 | null
55 | )
56 | var token: String? = if (Looper.myLooper() == Looper.getMainLooper()) {
57 | executor.submit(Callable {
58 | future.result.getString(AccountManager.KEY_AUTHTOKEN)
59 | }).get(100, TimeUnit.MILLISECONDS)
60 | } else future.result.getString(AccountManager.KEY_AUTHTOKEN)
61 | if (token == null) token = accountManager.peekAuthToken(owner, credentialType)
62 | if (token == null) {
63 | return null
64 | }
65 |
66 | val dataKeys = accountManager.getUserData(owner, "keys_${owner.type}_$credentialType")
67 | ?.let { Base64.decode(it, DEFAULT).toString() }
68 | ?.split(",")
69 | return Credentials(
70 | token,
71 | dataKeys
72 | ?.associate {
73 | it to accountManager.getUserData(owner, createDataKey(credentialType, it))
74 | }
75 | )
76 | }
77 |
78 | override fun removeCredentials(owner: Account, credentialType: String) {
79 | getCredentials(owner, credentialType)?.let { credential ->
80 | accountManager.invalidateAuthToken(owner.type, credential.token)
81 | val dataKeys = accountManager.getUserData(owner, "keys_${owner.type}_$credentialType")
82 | ?.let { Base64.decode(it, DEFAULT).toString() }
83 | ?.split(",")
84 | dataKeys?.forEach {
85 | accountManager.setUserData(
86 | owner,
87 | createDataKey(credentialType, it),
88 | null
89 | )
90 | }
91 | }
92 | }
93 |
94 | override fun storeCredentials(owner: Account, credentialType: String, credentials: Credentials) {
95 | accountManager.setAuthToken(owner, credentialType, credentials.token)
96 | val data = credentials.data
97 | if (data != null) {
98 | val dataKeys = data.keys
99 | .map { Base64.encodeToString(it.toByteArray(), DEFAULT) }
100 | .joinToString { it }
101 | accountManager.setUserData(owner, "keys_${owner.type}_$credentialType", dataKeys)
102 | data.forEach { (key, value) ->
103 | accountManager.setUserData(owner, createDataKey(credentialType, key), value)
104 | }
105 | }
106 | }
107 | }
108 |
--------------------------------------------------------------------------------
/demo-android/src/main/java/com/andretietz/retroauth/demo/screen/main/MainActivity.kt:
--------------------------------------------------------------------------------
1 | package com.andretietz.retroauth.demo.screen.main
2 |
3 | import android.os.Bundle
4 | import android.view.Menu
5 | import android.view.MenuInflater
6 | import android.view.MenuItem
7 | import android.view.View
8 | import android.webkit.CookieManager
9 | import android.widget.Toast
10 | import androidx.activity.viewModels
11 | import androidx.appcompat.app.AppCompatActivity
12 | import androidx.lifecycle.Lifecycle
13 | import androidx.lifecycle.flowWithLifecycle
14 | import androidx.lifecycle.lifecycleScope
15 | import androidx.recyclerview.widget.LinearLayoutManager
16 | import com.andretietz.retroauth.AndroidAccountManagerOwnerStorage
17 | import com.andretietz.retroauth.demo.R
18 | import com.andretietz.retroauth.demo.databinding.ActivityRepositoryListBinding
19 | import dagger.hilt.android.AndroidEntryPoint
20 | import kotlinx.coroutines.flow.launchIn
21 | import kotlinx.coroutines.flow.onEach
22 | import javax.inject.Inject
23 |
24 | @AndroidEntryPoint
25 | class MainActivity : AppCompatActivity() {
26 |
27 | private val viewModel: MainViewModel by viewModels()
28 |
29 | @Inject
30 | lateinit var ownerStorage: AndroidAccountManagerOwnerStorage
31 |
32 | private lateinit var binding: ActivityRepositoryListBinding
33 |
34 | private val switchAccount = registerForActivityResult(SwitchAccountContract()) { account ->
35 | account?.let { ownerStorage.switchActiveOwner(it) }
36 | }
37 |
38 | override fun onCreate(savedInstanceState: Bundle?) {
39 | super.onCreate(savedInstanceState)
40 | binding = ActivityRepositoryListBinding.inflate(layoutInflater)
41 | setContentView(binding.root)
42 | val adapter = RepositoryAdapter()
43 |
44 | binding.repositoryList.layoutManager = LinearLayoutManager(this)
45 | binding.repositoryList.adapter = adapter
46 |
47 | binding.swipeToRefresh.setOnRefreshListener { viewModel.loadRepositories() }
48 |
49 | viewModel.state.flowWithLifecycle(this.lifecycle, Lifecycle.State.STARTED)
50 | .onEach {
51 | binding.swipeToRefresh.isRefreshing = false
52 | when (it) {
53 | is MainViewState.InitialState -> {
54 | adapter.update(emptyList())
55 | binding.repositoryList.visibility = View.GONE
56 | binding.textEmpty.visibility = View.VISIBLE
57 | }
58 | is MainViewState.RepositoryUpdate -> {
59 | binding.repositoryList.visibility = View.VISIBLE
60 | binding.textEmpty.visibility = View.GONE
61 | adapter.update(it.repos)
62 | }
63 | is MainViewState.Error -> showError(it.throwable)
64 | is MainViewState.LoginSuccess<*> -> {
65 | binding.repositoryList.visibility = View.VISIBLE
66 | binding.textEmpty.visibility = View.GONE
67 | show("Login success!")
68 | }
69 | is MainViewState.LogoutSuccess -> {
70 | binding.repositoryList.visibility = View.GONE
71 | binding.textEmpty.visibility = View.VISIBLE
72 | show("Logout success!")
73 | }
74 | }
75 | }
76 | .launchIn(lifecycleScope)
77 |
78 | }
79 |
80 | override fun onCreateOptionsMenu(menu: Menu): Boolean {
81 | val inflater: MenuInflater = menuInflater
82 | inflater.inflate(R.menu.menu, menu)
83 | return true
84 | }
85 |
86 | override fun onOptionsItemSelected(item: MenuItem): Boolean {
87 | return when (item.itemId) {
88 | R.id.menuitem_add_account -> {
89 | viewModel.addAccount()
90 | true
91 | }
92 | R.id.menuitem_invalidate_token -> {
93 | viewModel.invalidateTokens()
94 | true
95 | }
96 | R.id.menuitem_switch_accounts -> {
97 | switchAccount.launch(viewModel.getCurrentAccount())
98 | true
99 | }
100 | R.id.menuitem_logout -> {
101 | viewModel.logout()
102 | true
103 | }
104 | else -> super.onOptionsItemSelected(item)
105 | }
106 | }
107 |
108 | private fun show(toShow: String) {
109 | Toast.makeText(applicationContext, toShow, Toast.LENGTH_SHORT).show()
110 | }
111 |
112 | private fun showError(error: Throwable) = show(error.toString())
113 | }
114 |
--------------------------------------------------------------------------------
/android-accountmanager/src/main/java/com/andretietz/retroauth/AccountAuthenticator.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (c) 2015 Andre Tietz
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.andretietz.retroauth
18 |
19 | import android.accounts.AbstractAccountAuthenticator
20 | import android.accounts.Account
21 | import android.accounts.AccountAuthenticatorResponse
22 | import android.accounts.AccountManager
23 | import android.accounts.NetworkErrorException
24 | import android.annotation.SuppressLint
25 | import android.content.Context
26 | import android.content.Intent
27 | import android.os.Bundle
28 | import android.util.Log
29 |
30 | /**
31 | * This AccountAuthenticator is a very basic implementation of Android's
32 | * [AbstractAccountAuthenticator]. This implementation is intentional as empty as it is. Cause of this is, that
33 | * it's executed in a different process, which makes it difficult to provide login endpoints from
34 | * the app process in here.
35 | *
36 | * NOTE: This class cannot be replaced with a kotlin version yet, since Android cannot load Authenticators
37 | * that are non java once
38 | */
39 | class AccountAuthenticator(
40 | context: Context,
41 | private val action: String,
42 | private val cleanupUserData: (account: Account) -> Unit
43 | ) : AbstractAccountAuthenticator(context) {
44 |
45 | override fun addAccount(
46 | response: AccountAuthenticatorResponse,
47 | accountType: String,
48 | authCredentialType: String?,
49 | requiredFeatures: Array?,
50 | options: Bundle
51 | ) = createAuthBundle(response, action, accountType, authCredentialType, null)
52 |
53 | override fun getAuthToken(
54 | response: AccountAuthenticatorResponse,
55 | account: Account,
56 | authTokenType: String,
57 | options: Bundle
58 | ) = createAuthBundle(response, action, account.type, authTokenType, account.name)
59 |
60 | /**
61 | * Creates an Intent to open the Activity to login.
62 | *
63 | * @param response needed parameter
64 | * @param accountType The account Type
65 | * @param credentialType The requested credential-type
66 | * @param accountName The name of the account
67 | * @return a bundle to open the activity
68 | */
69 | private fun createAuthBundle(
70 | response: AccountAuthenticatorResponse,
71 | action: String,
72 | accountType: String,
73 | credentialType: String?,
74 | accountName: String?
75 | ): Bundle = Bundle().apply {
76 | putParcelable(
77 | AccountManager.KEY_INTENT,
78 | Intent(action).apply {
79 | putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response)
80 | putExtra(AccountManager.KEY_ACCOUNT_TYPE, accountType)
81 | putExtra(KEY_CREDENTIAL_TYPE, credentialType)
82 | accountName?.let {
83 | putExtra(AccountManager.KEY_ACCOUNT_NAME, it)
84 | }
85 | })
86 | }
87 |
88 | override fun confirmCredentials(
89 | response: AccountAuthenticatorResponse,
90 | account: Account,
91 | options: Bundle?
92 | ) = null
93 |
94 | override fun editProperties(response: AccountAuthenticatorResponse, accountType: String) = null
95 |
96 | override fun getAuthTokenLabel(authCredentialType: String) = null
97 |
98 | override fun updateCredentials(
99 | response: AccountAuthenticatorResponse,
100 | account: Account,
101 | authCredentialType: String,
102 | options: Bundle
103 | ): Bundle? = null
104 |
105 | override fun hasFeatures(
106 | response: AccountAuthenticatorResponse,
107 | account: Account,
108 | features: Array
109 | ) = null
110 |
111 | @SuppressLint("CheckResult")
112 | @Throws(NetworkErrorException::class)
113 | override fun getAccountRemovalAllowed(response: AccountAuthenticatorResponse, account: Account): Bundle? {
114 | val result = super.getAccountRemovalAllowed(response, account)
115 | if (
116 | result != null && result.containsKey(AccountManager.KEY_BOOLEAN_RESULT) &&
117 | result.getBoolean(AccountManager.KEY_BOOLEAN_RESULT, false)) {
118 | try {
119 | cleanupUserData(account)
120 | } catch (exception: Exception) {
121 | Log.w("AuthenticationService", "Your cleanup method threw an exception:", exception)
122 | }
123 | }
124 | return result
125 | }
126 |
127 | companion object {
128 | internal const val KEY_CREDENTIAL_TYPE = "account_credential_type"
129 | }
130 | }
131 |
--------------------------------------------------------------------------------
/gradlew:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | ##############################################################################
4 | ##
5 | ## Gradle start up script for UN*X
6 | ##
7 | ##############################################################################
8 |
9 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
10 | DEFAULT_JVM_OPTS=""
11 |
12 | APP_NAME="Gradle"
13 | APP_BASE_NAME=`basename "$0"`
14 |
15 | # Use the maximum available, or set MAX_FD != -1 to use that value.
16 | MAX_FD="maximum"
17 |
18 | warn ( ) {
19 | echo "$*"
20 | }
21 |
22 | die ( ) {
23 | echo
24 | echo "$*"
25 | echo
26 | exit 1
27 | }
28 |
29 | # OS specific support (must be 'true' or 'false').
30 | cygwin=false
31 | msys=false
32 | darwin=false
33 | case "`uname`" in
34 | CYGWIN* )
35 | cygwin=true
36 | ;;
37 | Darwin* )
38 | darwin=true
39 | ;;
40 | MINGW* )
41 | msys=true
42 | ;;
43 | esac
44 |
45 | # Attempt to set APP_HOME
46 | # Resolve links: $0 may be a link
47 | PRG="$0"
48 | # Need this for relative symlinks.
49 | while [ -h "$PRG" ] ; do
50 | ls=`ls -ld "$PRG"`
51 | link=`expr "$ls" : '.*-> \(.*\)$'`
52 | if expr "$link" : '/.*' > /dev/null; then
53 | PRG="$link"
54 | else
55 | PRG=`dirname "$PRG"`"/$link"
56 | fi
57 | done
58 | SAVED="`pwd`"
59 | cd "`dirname \"$PRG\"`/" >/dev/null
60 | APP_HOME="`pwd -P`"
61 | cd "$SAVED" >/dev/null
62 |
63 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
64 |
65 | # Determine the Java command to use to start the JVM.
66 | if [ -n "$JAVA_HOME" ] ; then
67 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
68 | # IBM's JDK on AIX uses strange locations for the executables
69 | JAVACMD="$JAVA_HOME/jre/sh/java"
70 | else
71 | JAVACMD="$JAVA_HOME/bin/java"
72 | fi
73 | if [ ! -x "$JAVACMD" ] ; then
74 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
75 |
76 | Please set the JAVA_HOME variable in your environment to match the
77 | location of your Java installation."
78 | fi
79 | else
80 | JAVACMD="java"
81 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
82 |
83 | Please set the JAVA_HOME variable in your environment to match the
84 | location of your Java installation."
85 | fi
86 |
87 | # Increase the maximum file descriptors if we can.
88 | if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then
89 | MAX_FD_LIMIT=`ulimit -H -n`
90 | if [ $? -eq 0 ] ; then
91 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
92 | MAX_FD="$MAX_FD_LIMIT"
93 | fi
94 | ulimit -n $MAX_FD
95 | if [ $? -ne 0 ] ; then
96 | warn "Could not set maximum file descriptor limit: $MAX_FD"
97 | fi
98 | else
99 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
100 | fi
101 | fi
102 |
103 | # For Darwin, add options to specify how the application appears in the dock
104 | if $darwin; then
105 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
106 | fi
107 |
108 | # For Cygwin, switch paths to Windows format before running java
109 | if $cygwin ; then
110 | APP_HOME=`cygpath --path --mixed "$APP_HOME"`
111 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
112 | JAVACMD=`cygpath --unix "$JAVACMD"`
113 |
114 | # We build the pattern for arguments to be converted via cygpath
115 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
116 | SEP=""
117 | for dir in $ROOTDIRSRAW ; do
118 | ROOTDIRS="$ROOTDIRS$SEP$dir"
119 | SEP="|"
120 | done
121 | OURCYGPATTERN="(^($ROOTDIRS))"
122 | # Add a user-defined pattern to the cygpath arguments
123 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then
124 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
125 | fi
126 | # Now convert the arguments - kludge to limit ourselves to /bin/sh
127 | i=0
128 | for arg in "$@" ; do
129 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
130 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
131 |
132 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
133 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
134 | else
135 | eval `echo args$i`="\"$arg\""
136 | fi
137 | i=$((i+1))
138 | done
139 | case $i in
140 | (0) set -- ;;
141 | (1) set -- "$args0" ;;
142 | (2) set -- "$args0" "$args1" ;;
143 | (3) set -- "$args0" "$args1" "$args2" ;;
144 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;;
145 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
146 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
147 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
148 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
149 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
150 | esac
151 | fi
152 |
153 | # Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules
154 | function splitJvmOpts() {
155 | JVM_OPTS=("$@")
156 | }
157 | eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS
158 | JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME"
159 |
160 | exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@"
161 |
--------------------------------------------------------------------------------
/sqlite/src/test/kotlin/com/andretietz/retroauth/SQLiteOwnerStoreTest.kt:
--------------------------------------------------------------------------------
1 | package com.andretietz.retroauth
2 |
3 | import com.andretietz.retroauth.sqlite.CredentialTable
4 | import com.andretietz.retroauth.sqlite.DataTable
5 | import com.andretietz.retroauth.sqlite.DatabaseUser
6 | import com.andretietz.retroauth.sqlite.UserTable
7 | import com.andretietz.retroauth.sqlite.data.Account
8 | import com.nhaarman.mockitokotlin2.doAnswer
9 | import com.nhaarman.mockitokotlin2.mock
10 | import org.assertj.core.api.Assertions.assertThat
11 | import org.assertj.core.api.Assertions.fail
12 | import org.jetbrains.exposed.sql.Database
13 | import org.jetbrains.exposed.sql.SchemaUtils
14 | import org.jetbrains.exposed.sql.StdOutSqlLogger
15 | import org.jetbrains.exposed.sql.addLogger
16 | import org.jetbrains.exposed.sql.transactions.transaction
17 | import org.junit.Test
18 | import org.mockito.ArgumentMatchers.anyString
19 |
20 |
21 | class SQLiteOwnerStoreTest {
22 | private val database = Database.connect("jdbc:h2:mem:test", driver = "org.h2.Driver")
23 | private val ownerStore: OwnerStorage = SQLiteOwnerStore(database) {
24 | DatabaseUser.new {
25 | name = "some name"
26 | email = "some@name.com"
27 | }
28 | .let { Account(it.id.value, it.name, it.email) }
29 | }
30 |
31 | @Test
32 | fun `calls the closure to create an owner`() {
33 | transaction(database) {
34 | addLogger(StdOutSqlLogger)
35 | SchemaUtils.create(UserTable, CredentialTable, DataTable)
36 | val owner = ownerStore.createOwner("type")
37 | assertThat(owner!!.name).isEqualTo("some name")
38 | assertThat(owner.email).isEqualTo("some@name.com")
39 | SchemaUtils.drop(UserTable, CredentialTable, DataTable)
40 | }
41 | }
42 |
43 | @Test
44 | fun `cannot create the same user twice`() {
45 | transaction(database) {
46 | addLogger(StdOutSqlLogger)
47 | SchemaUtils.create(UserTable, CredentialTable, DataTable)
48 | try {
49 | ownerStore.createOwner("type")
50 | ownerStore.createOwner("type")
51 | fail("You shouldn't be able to create the same user twice!")
52 | } catch (_: Throwable) {
53 | } finally {
54 | SchemaUtils.drop(UserTable, CredentialTable, DataTable)
55 | }
56 | }
57 | }
58 |
59 | @Test
60 | fun `newly created user is autmatically the active one`() {
61 | transaction(database) {
62 | addLogger(StdOutSqlLogger)
63 | SchemaUtils.create(UserTable, CredentialTable, DataTable)
64 | val owner = ownerStore.createOwner("type")
65 | val activeOwner = ownerStore.getActiveOwner()
66 |
67 | assertThat(activeOwner).isEqualTo(owner)
68 | SchemaUtils.drop(UserTable, CredentialTable, DataTable)
69 | }
70 | }
71 |
72 | @Test
73 | fun `getting the owner requires the correct name`() {
74 | transaction(database) {
75 | addLogger(StdOutSqlLogger)
76 | SchemaUtils.create(UserTable, CredentialTable, DataTable)
77 | ownerStore.createOwner("type")
78 |
79 | val nonExistent = ownerStore.getOwner("nonexistent")
80 | val owner = ownerStore.getOwner("some name")
81 |
82 | assertThat(nonExistent).isNull()
83 | assertThat(owner).isNotNull
84 |
85 | SchemaUtils.drop(UserTable, CredentialTable, DataTable)
86 | }
87 | }
88 |
89 | @Test
90 | fun `getOwners returns all created users`() {
91 | val accounts = listOf(
92 | Account(1, "one", "one"),
93 | Account(2, "two", "two")
94 | )
95 | var ac = 0
96 | val mockCreateUser = mock<(credentialType: String) -> Account?> {
97 | on { it(anyString()) } doAnswer {
98 | val account = accounts[(ac++) % 2]
99 | DatabaseUser.new {
100 | name = account.name
101 | email = account.email
102 | }
103 | account
104 | }
105 | }
106 | val ownerStore = SQLiteOwnerStore(database, mockCreateUser)
107 | transaction(database) {
108 | addLogger(StdOutSqlLogger)
109 | SchemaUtils.create(UserTable, CredentialTable, DataTable)
110 | ownerStore.createOwner("type")
111 | ownerStore.createOwner("type")
112 |
113 | assertThat(ownerStore.getOwners().size).isEqualTo(2)
114 |
115 | SchemaUtils.drop(UserTable, CredentialTable, DataTable)
116 | }
117 | }
118 |
119 |
120 | @Test
121 | fun `remove an owner actually removes it`() {
122 | transaction(database) {
123 | addLogger(StdOutSqlLogger)
124 | SchemaUtils.create(UserTable, CredentialTable, DataTable)
125 | val owner = ownerStore.createOwner("type")!!
126 |
127 | assertThat(ownerStore.removeOwner(owner)).isEqualTo(true)
128 | assertThat(ownerStore.getActiveOwner()).isNull()
129 | assertThat(ownerStore.getOwners().size).isEqualTo(0)
130 |
131 | SchemaUtils.drop(UserTable, CredentialTable, DataTable)
132 | }
133 | }
134 |
135 | @Test
136 | fun `switching the owner works`() {
137 | val accounts = listOf(
138 | Account(1, "one", "one"),
139 | Account(2, "two", "two")
140 | )
141 | var ac = 0
142 | val mockCreateUser = mock<(credentialType: String) -> Account?> {
143 | on { it(anyString()) } doAnswer {
144 | val account = accounts[(ac++) % 2]
145 | DatabaseUser.new {
146 | name = account.name
147 | email = account.email
148 | }
149 | account
150 | }
151 | }
152 | val ownerStore = SQLiteOwnerStore(database, mockCreateUser)
153 | transaction(database) {
154 | addLogger(StdOutSqlLogger)
155 | SchemaUtils.create(UserTable, CredentialTable, DataTable)
156 |
157 | val owner1 = ownerStore.createOwner("type")!!
158 | val owner2 = ownerStore.createOwner("type")!!
159 |
160 | assertThat(owner1).isNotNull
161 | assertThat(owner2).isNotNull
162 | assertThat(ownerStore.getOwners().size).isEqualTo(2)
163 |
164 | assertThat(ownerStore.getActiveOwner()).isEqualTo(owner2)
165 |
166 | ownerStore.switchActiveOwner(owner1)
167 |
168 | assertThat(ownerStore.getActiveOwner()).isEqualTo(owner1)
169 |
170 | SchemaUtils.drop(UserTable, CredentialTable, DataTable)
171 | }
172 | }
173 |
174 | }
175 |
--------------------------------------------------------------------------------
/retroauth/src/main/java/com/andretietz/retroauth/CredentialInterceptor.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (c) 2016 Andre Tietz
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.andretietz.retroauth
18 |
19 | import okhttp3.Interceptor
20 | import okhttp3.Request
21 | import okhttp3.Response
22 | import retrofit2.Invocation
23 | import java.util.concurrent.Executors
24 | import java.util.concurrent.atomic.AtomicInteger
25 | import java.util.concurrent.atomic.AtomicReference
26 | import java.util.concurrent.locks.ReentrantLock
27 |
28 | /**
29 | * This interceptor intercepts the okhttp requests and checks if authentication is required.
30 | * If so, it tries to get the owner of the credential, then tries to get the credential and
31 | * applies the credential to the request
32 | *
33 | * @param a type that represents the owner of a credential. Since there could be multiple users on one client.
34 | * @param credential that should be added to the request
35 | */
36 | class CredentialInterceptor(
37 | private val authenticator: Authenticator,
38 | private val ownerManager: OwnerStorage,
39 | private val credentialStorage: CredentialStorage
40 | ) : Interceptor {
41 |
42 | companion object {
43 | private val refreshLock = AtomicReference(AccountTokenLock())
44 | private const val HASH_PRIME = 31
45 | }
46 |
47 | private val registration = mutableMapOf()
48 |
49 | private val executor = Executors.newSingleThreadExecutor()
50 |
51 | @Suppress("Detekt.RethrowCaughtException")
52 | override fun intercept(chain: Interceptor.Chain): Response {
53 | var response: Response
54 | var request = chain.request()
55 | // get the credential type required by this request
56 | val authRequestType = findRequestType(request) ?: return chain.proceed(request)
57 | var refreshRequested = false
58 | var credential: Credentials
59 | var owner: OWNER?
60 | var tryCount = 0
61 | do {
62 | try {
63 | lock()
64 | owner = ownerManager.getActiveOwner()
65 | ?: ownerManager.getOwners().firstOrNull()
66 | ?.also { ownerManager.switchActiveOwner(it) }
67 | if (owner != null) {
68 | // get the credential of the owner
69 | val localToken =
70 | credentialStorage.getCredentials(owner, authRequestType.credentialType)
71 | ?: throw AuthenticationRequiredException()
72 | // if the credential is still valid and no refresh has been requested
73 | credential = if (authenticator.isCredentialValid(localToken) && !refreshRequested) {
74 | localToken
75 | } else {
76 | // try to refreshing the credentials
77 | val refreshedToken =
78 | authenticator.refreshCredentials(owner, authRequestType.credentialType, localToken)
79 | if (refreshedToken != null) {
80 | // if the credential was refreshed, store it
81 | credentialStorage
82 | .storeCredentials(owner, authRequestType.credentialType, refreshedToken)
83 | refreshedToken
84 | } else {
85 | // otherwise remove the current credential from the storage
86 | credentialStorage
87 | .removeCredentials(owner, authRequestType.credentialType)
88 | // and use the "old" credential
89 | localToken
90 | }
91 | }
92 | // authenticate the request using the credential
93 | request = authenticator.authenticateRequest(request, credential)
94 | } else {
95 | executor.submit {
96 | // async creation of an owner
97 | ownerManager.createOwner(authRequestType.credentialType)
98 | }
99 | // cannot authorize request -> cancel running request
100 | throw AuthenticationRequiredException()
101 | }
102 | } catch (error: Throwable) {
103 | // store the error for the other requests that might be queued
104 | refreshLock.get().error = error
105 | throw error
106 | } finally {
107 | unlock()
108 | }
109 | // execute the request
110 | response = chain.proceed(request)
111 | refreshRequested = authenticator.refreshRequired(++tryCount, response)
112 | if (refreshRequested) response.close()
113 | } while (refreshRequested)
114 | return response
115 | }
116 |
117 | @Throws(Throwable::class)
118 | private fun lock() {
119 | if (!refreshLock.get().lock.tryLock()) {
120 | refreshLock.get().count.incrementAndGet()
121 | refreshLock.get().lock.lock()
122 | refreshLock.get().error?.let { throw it }
123 | } else {
124 | refreshLock.get().count.incrementAndGet()
125 | }
126 | }
127 |
128 | private fun unlock() {
129 | if (refreshLock.get().count.decrementAndGet() <= 0) {
130 | refreshLock.get().error = null
131 | }
132 | refreshLock.get().lock.unlock()
133 | }
134 |
135 | private fun findRequestType(
136 | request: Request
137 | ): RequestType? {
138 | val key = request.url.hashCode() + HASH_PRIME * request.method.hashCode()
139 | return registration[key] ?: request.tag(Invocation::class.java)
140 | ?.method()
141 | ?.annotations
142 | ?.filterIsInstance()
143 | ?.firstOrNull()
144 | ?.let {
145 | RequestType(authenticator.getCredentialType(it.credentialType))
146 | }
147 | ?.also { registration[key] = it }
148 | }
149 |
150 | internal data class AccountTokenLock(
151 | val lock: ReentrantLock = ReentrantLock(),
152 | var error: Throwable? = null,
153 | var count: AtomicInteger = AtomicInteger(0)
154 | )
155 | }
156 |
--------------------------------------------------------------------------------
/quality/checkstyle.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 |
118 |
119 |
120 |
121 |
122 |
123 |
124 |
125 |
126 |
127 |
128 |
129 |
130 |
131 |
132 |
133 |
134 |
135 |
136 |
137 |
138 |
139 |
140 |
141 |
142 |
143 |
144 |
145 |
146 |
147 |
148 |
--------------------------------------------------------------------------------
/android-accountmanager/src/main/java/com/andretietz/retroauth/AuthenticationActivity.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (c) 2016 Andre Tietz
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.andretietz.retroauth
18 |
19 | import android.accounts.Account
20 | import android.accounts.AccountAuthenticatorResponse
21 | import android.accounts.AccountManager
22 | import android.app.Activity
23 | import android.content.Intent
24 | import android.os.Build
25 | import android.os.Bundle
26 | import androidx.appcompat.app.AppCompatActivity
27 |
28 | /**
29 | * Your activity that's supposed to create the account (i.e. Login{@link android.app.Activity}) has to implement this.
30 | * It'll provide functionality to {@link #storeCredentials(Account, String, String)} and
31 | * {@link #storeUserData(Account, String, String)} when logging in. In case your service is providing a refresh token,
32 | * use {@link #storeCredentials(Account, String, String, String)}. This will additionally store a refresh token that
33 | * can be used in {@link Authenticator#validateResponse(int, okhttp3.Response, TokenStorage, Object, Object, Object)}
34 | * to update the access-token
35 | */
36 | abstract class AuthenticationActivity : AppCompatActivity() {
37 |
38 | private var accountAuthenticatorResponse: AccountAuthenticatorResponse? = null
39 | private lateinit var accountType: String
40 | private lateinit var accountManager: AccountManager
41 | private var credentialType: String? = null
42 | private lateinit var resultBundle: Bundle
43 | private lateinit var credentialStorage: AndroidAccountManagerCredentialStorage
44 | private val ownerManager by lazy {
45 | AndroidAccountManagerOwnerStorage(
46 | application,
47 | accountType
48 | )
49 | }
50 |
51 | override fun onCreate(savedInstanceState: Bundle?) {
52 | super.onCreate(savedInstanceState)
53 | accountManager = AccountManager.get(application)
54 | credentialStorage = AndroidAccountManagerCredentialStorage(application)
55 |
56 | accountAuthenticatorResponse =
57 | intent.getParcelableExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE)
58 | accountAuthenticatorResponse?.onRequestContinued()
59 |
60 | val accountType = intent.getStringExtra(AccountManager.KEY_ACCOUNT_TYPE)
61 | if (accountType == null) {
62 | accountAuthenticatorResponse?.onError(AccountManager.ERROR_CODE_CANCELED, "canceled")
63 | error(
64 | "This Activity cannot be started without the \"%s\" extra in the intent! " +
65 | "Use the \"createAccount\"-Method of the \"%s\" for opening the Login manually."
66 | .format(AccountManager.KEY_ACCOUNT_TYPE, OwnerStorage::class.java.simpleName)
67 | )
68 | }
69 | this.accountType = accountType
70 | credentialType = intent.getStringExtra(AccountAuthenticator.KEY_CREDENTIAL_TYPE)
71 |
72 | resultBundle = Bundle()
73 | resultBundle.putString(AccountManager.KEY_ACCOUNT_TYPE, accountType)
74 | }
75 |
76 | /**
77 | * This method stores an authentication Token to a specific account.
78 | *
79 | * @param account Account you want to store the credentials for
80 | * @param credentialType type of the credentials you want to store
81 | * @param credential the AndroidToken
82 | */
83 | fun storeCredentials(account: Account, credentialType: String, credential: Credentials) {
84 | credentialStorage.storeCredentials(account, credentialType, credential)
85 | }
86 |
87 | /**
88 | * With this you can store some additional userdata in key-value-pairs to the account.
89 | *
90 | * @param account Account you want to store information for
91 | * @param key the key for the data
92 | * @param value the actual data you want to store
93 | */
94 | fun storeUserData(account: Account, key: String, value: String?) {
95 | accountManager.setUserData(account, key, value)
96 | }
97 |
98 | /**
99 | * This method will finish the login process. Depending on the finishActivity flag, the activity
100 | * will be finished or not. The account which is reached into this method will be set as
101 | * "current" account.
102 | *
103 | * @param account Account you want to set as current active
104 | * @param finishActivity when `true`, the activity will be finished after finalization.
105 | */
106 | @JvmOverloads
107 | fun finalizeAuthentication(account: Account, finishActivity: Boolean = true) {
108 | resultBundle.putString(AccountManager.KEY_ACCOUNT_NAME, account.name)
109 | ownerManager.switchActiveOwner(account)
110 | if (finishActivity) finish()
111 | }
112 |
113 | /**
114 | * Tries finding an existing account with the given name.
115 | * It creates a new Account if it couldn't find it
116 | *
117 | * @param accountName Name of the account you're searching for
118 | * @return The account if found, or a newly created one
119 | */
120 | fun createOrGetAccount(accountName: String): Account {
121 | // if this is a relogin
122 | val accountList = accountManager.getAccountsByType(accountType)
123 | for (account in accountList) {
124 | if (account.name == accountName)
125 | return account
126 | }
127 | val account = Account(accountName, accountType)
128 | accountManager.addAccountExplicitly(account, null, null)
129 | return account
130 | }
131 |
132 | /**
133 | * If for some reason an account was created already and the login couldn't complete successfully, you can user this
134 | * method to remove this account
135 | *
136 | * @param account to remove
137 | */
138 | @Suppress("DEPRECATION")
139 | fun removeAccount(account: Account) {
140 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) {
141 | accountManager.removeAccount(account, null, null, null)
142 | } else {
143 | accountManager.removeAccount(account, null, null)
144 | }
145 | }
146 |
147 | /**
148 | * Sends the result or a Constants.ERROR_CODE_CANCELED error if a result isn't present.
149 | */
150 | override fun finish() {
151 | if (accountAuthenticatorResponse != null) {
152 | accountAuthenticatorResponse?.onResult(resultBundle)
153 | accountAuthenticatorResponse = null
154 | } else {
155 | if (resultBundle.containsKey(AccountManager.KEY_ACCOUNT_NAME)) {
156 | val intent = Intent()
157 | intent.putExtras(resultBundle)
158 | setResult(Activity.RESULT_OK, intent)
159 | } else {
160 | setResult(Activity.RESULT_CANCELED)
161 | }
162 | }
163 | super.finish()
164 | }
165 |
166 | /**
167 | * @return The requested account type if available. otherwise `null`
168 | */
169 | fun getRequestedAccountType() = accountType
170 |
171 | /**
172 | * @return The requested token type if available. otherwise `null`
173 | */
174 | fun getRequestedCredentialType() = credentialType
175 | }
176 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | ## 4.0.0 (2021)
2 | * Kotlin script migration
3 | * API Breaking changes
4 | * Interfaces Authenticator, OwnerStorage, CredentialStorage have been changed
5 | * removed all usages of `Future` or callbacks
6 | * Generics have been reduced to a minimum (from production experiences, it was not needed)
7 | * Removed the owner-type from the api
8 | * Credential is now a predefined data object, that should cover all cases
9 | * credential-type is now just a string
10 | ## 3.2.0 (2021-06-08)
11 | * Removing the Retrofit CallAdapter in order to enrich the requests using [this method](https://andretietz.com/2021/04/06/custom-retrofit2-annotations-revisited/)
12 | * using the github api instead of facebook in the demo.
13 | * Renaming retroauth-android to android-accountmanager. WARNING: This changes the project-import!!!
14 | * Renaming AndroidOwnerStorage to AndroidAccountManagerOwnerStorage and AndroidCredentialStorage to AndroidAccountManagerCredentialStorage
15 | ## 3.1.0 (2021-01-22)
16 | * Removing [startup hack](https://andretietz.medium.com/auto-initialize-your-android-library-2349daf06920) with google version of it.
17 | * increasing min SDK level from 14 to 21
18 | ## 3.0.1 (2020-02-26)
19 | * opened CredentialInterceptor for use outside the library
20 | ## 3.0.0 (2020-01-20)
21 | * Renaming Token -> Credentials
22 | * Converted the project to Kotlin
23 | * When getting the credentials, it CAN automatically read user data items, which are defined in the CredentialType
24 | * API changes
25 | * Solving problem with multiple authenticated requests.
26 | * Renamed Provider to CredentialProvider and added functionality
27 | * Better separation of OwnerStorage and CredentialStorage
28 | * Fixed Demo app which is using Facebook with [scribe](https://github.com/scribejava/scribejava)
29 | * Added functionalities to refresh the credentials before the authenticated call is called.
30 | * Renaming TokenProvider to Authenticator
31 | * Callback method when the account is removed from the Android AccountManager
32 |
33 | ## 2.3.1 (2018-01-15)
34 | * Updating release script
35 | * Fixing Exception when Exception is thrown during the actual call (was always AuthenticationCanceledException)
36 | * Renamed core project to retroauth instead of retroauth-core
37 |
38 | ## 2.3.0 (2017-09-07)
39 | * Renaming ContextManager to ActivityManager and focusing on providing a Nullable Activity. No Context will be provided anymore! Due to this change this is a breaking change.
40 | * Doesn't use ContentProvider workaround for getting the Application Object. It seemed nice but was difficult when using multiple processes.
41 |
42 | ## 2.2.2 (2017-08-21)
43 | * Adding the hashCode method for the AndroidTokenType, so that the locking is actually working.
44 |
45 | ## 2.2.1 (2017-06-06)
46 | * If Request-Locking is enabled, it's locking (only one request at once) requests over multiple retroauth instances.
47 |
48 | ## 2.2.0 (2017-05-22)
49 | * Removed Deprecated Methods
50 | * Changes in the CallAdapter implementation update to retrofit 2.3.0
51 |
52 | ## 2.1.6 (2017-01-09)
53 | * Added functionalities to switch accounts easily
54 |
55 | ## 2.1.5 (2017-01-05)
56 | * Fixed a bug in the ContextManager. Activity Stack was used a bit "optimistic"
57 |
58 | ## 2.1.4 (2016-08-24)
59 | * retroauth-android
60 | * set fixed appcompat minimum version to 22.1.0
61 |
62 | ## 2.1.3 (2016-08-16)
63 | * retroauth-android
64 | * added robolectric to enhance test coverage
65 | * bugfix in ContextManager
66 |
67 | ## 2.1.2 (2016-08-02)
68 | * retroauth-android:
69 | * Authentication can be finalized without finishing the activity itself
70 | * Adding and removing accounts using the AuthAccountManager can have some optional callbacks, which notifies you, when the system created/removed the account
71 |
72 | ## 2.1.1 (2016-07-27)
73 | * retroauth-core:
74 | * Removed method "createType" from TokenStorage
75 | * Created TokenTypeFactory, which can be passed optionally into the AuthenticationHandler
76 |
77 | ## 2.1.0 (2016-07-25)
78 | * retroauth-core:
79 | * breaking improvement
80 | * Switching from String[] to int[], which is easier to handle on android
81 | * retroauth-android:
82 | * Some of the methods of the AuthAccountManager don't need a Context anymore
83 | * removed method "getActiveUserToken" from AuthAccountManager, 'cause it's not necessary anymore
84 | * Update dependencies
85 | * retrofit 2.1.0 (retroauth-core)
86 | * appcompat 24.1.1 (retroauth-android)
87 |
88 | ## 2.0.0 (2016-06-15)
89 |
90 | * Complete rebuild, to be able to work with retrofit2
91 | * Removed rxjava as dependency
92 | * Works as well with plain java
93 | * added retroauth-android library (for android accountmanager needs)
94 | * added java demo (google, javafx)
95 | * added android demo (google, webview)
96 | * No Context required for creating the Retrofit object
97 |
98 |
99 | ## 1.0.4 (2015-11-02)
100 |
101 | * Demo App:
102 | * Added Github authentication as an example
103 | * Permission GET_ACCOUNTS, MANAGE_ACCOUNTS, USE_CREDENTIALS, AUTHENTICATE_ACCOUNTS are now limited to APIs below 23 (No Runtime Permissions to ask the user for anymore)
104 | * Dependency Updates:
105 | * appcompat 23.1.0
106 | * rxjava 1.0.15
107 | * (Demo App:) rxandroid 1.0.1
108 | * Bugfixes:
109 | * there were several issues regarding the relogin on a 401 on specific request types (blocking/async/rx)
110 |
111 |
112 | ## 1.0.3 (2015-08-29)
113 |
114 | * Storing multiple credentials in the AuthenticationActivity
115 | * Adding some sequence diagrams for a better understanding
116 | * Bugfixes:
117 | * Creating an instance of the LockingStrategy required a protected class as argument. fixed this.
118 |
119 | ## 1.0.2 (2015-08-19)
120 |
121 | * Dependency updates:
122 | * rxjava 1.0.14
123 | * appcompat 23.0.0
124 | * Introducing RequestStrategies
125 | * RequestStrategy
126 | * The most basic one, just executes the request without retrying
127 | * RetryAndInvalidateStrategy: based on RequestStrategy
128 | * Retries the request if it returns with 401 and invalidates the token, which was (obviously) not valid anymore
129 | * LockingStrategy: based on RetryAndInvalidateStrategy
130 | * only one request (of a tokentype) is executed at once. this is to prevent multiple login screens.
131 |
132 | ## 1.0.1 (2015-07-28)
133 |
134 | * Bugfix
135 | * When multiple authenticated requests were called at the same time, and the provided token was invalid at this time, multiple 401's were returned and multiple login activities were opened.
136 | * when you do multiple authenticated requests, there will be only one executed at one time. This is to avoid multiple 401's and multiple activities to open.
137 | * when a request has to wait for another one, it'll be executed as soon as the previous one returns.
138 |
139 | ## 1.0.0 (2015-07-21)
140 |
141 | * Bugfixes:
142 | * There was a major issue causing a crash, when trying to show the account chooser, after creating a new account
143 | * Unit Tests for the main functionalities
144 | * Javadocs for all classes
145 |
146 | ## 0.1.4-beta (2015-07-10)
147 |
148 | * Bugfix:
149 | * Right after the first login, the Token was not correctly appended to the TokenInterceptor
150 | * Lots of documentation
151 |
152 | ## 0.1.3-beta (2015-07-04)
153 |
154 | * Demo App:
155 | * can have unlimited amount of users (using any username and the password "test"
156 | * shows the active account in the title
157 |
158 | * Bugfix:
159 |
160 | The Token didn't invalidate on `AuthAccountManager#invalidateTokenFromActiveUser`
161 | * Added `AuthAccountManager#getUserData` to get the userdata of an active account, which were setup in `AuthenticationActivity#finalizeAuthentication`
162 |
163 | ## 0.1.2-beta (2015-07-04)
164 |
165 | * All retrofit request types are supported
166 | * rxjava
167 | * blocking
168 | * async
169 | * Added AuthAccountManager to simplify the Account handling with retroauth
170 |
171 | ## 0.1.1-beta (2015-06-30)
172 |
173 | * Multiple Accounts possible
174 |
175 | If the User has multiple accounts setup and an authenticated request is called, the user
176 | will see an account picker to choose between the accounts or create a new one
177 |
178 | ## 0.1.0-beta (2015-06-28)
179 |
180 | First public release
181 |
--------------------------------------------------------------------------------
/android-accountmanager/README.md:
--------------------------------------------------------------------------------
1 | # A simple way of calling authenticated requests using retrofit on android
2 | [](http://android-arsenal.com/details/1/2195)
3 | [](http://androidweekly.net/issues/issue-163)
4 | [](https://github.com/andretietz/retroauth/actions?query=workflow%3A%22Snapshot+build%22)
5 | ## Dependencies
6 | * [Retrofit](https://github.com/square/retrofit) 2.9.0
7 | * androidx.appcompat 1.3.0
8 | * kotlin-stdlib 1.5.10
9 |
10 | ## What does it do?
11 | If you call a request method, annotated with the authenticated annotation, it'll do the following steps:
12 | * Step 1: Checks if there already is an account in the Android AccountManager. If not, it'll open a LoginActivity (you choose which). If there already is an account, go on with step 2, If there's more than one account open an Dialog to pick an account.
13 | * Step 2: Tries to get the authentication credentials from the (choosen) account for authorizing the request. If there is no valid credential, your LoginActivity will open. After login go to Step 1.
14 | * Step 3: If no Login was required (credentials exists already), it sends the actual request.
15 | * Step 4: By implementing a Authenticator you can check the response (i.e. a 401 you will be able to refresh the token) and decide if you want to retry the request or not.
16 |
17 | ## How to use it?
18 |
19 | Add it as dependency:
20 | ```groovy
21 | implementation 'com.andretietz.retroauth:android-accountmanager:x.y.z'
22 | ```
23 | ## Setup
24 | ### 1. Define an Account-Type String
25 | The Account-Type should be unique for an app or company, depending on if you want to share the account in multiple apps of your company or not.
26 | I recommend using something like ```your.company.id.ACCOUNT```.
27 | ### 2. Define an Authentication Action.
28 | We'll use this String later in order to start our Login-Activity using an intent-filter in the manifest.
29 | This could be something like ```your.company.id.ACTION```
30 | ### 3. Create an Authentication Service.
31 | This Service is started whenever the Android OS is asked for a login of the in #1 provided Account-Type. It then uses the Action-String defined in #2 to show the Login.
32 | This must be a service since you can add create accounts within the account-settings.
33 |
34 | This is a very small implementation, that could look like this:
35 | ```kotlin
36 | class DemoAuthenticationService : AuthenticationService() {
37 | override fun getLoginAction(): String = "your.company.id.ACTION"
38 | // optionally to implement. Get's called, when the account will be removed
39 | override fun cleanupAccount(account: Account) {
40 | // Here you can trigger your account cleanup (userdata wiping)
41 | Timber.e("Remove account: ${account.name}")
42 | }
43 | }
44 | ```
45 | ### 4. Creating the link to the authenticator
46 | With this xml in the res/xml folder of our project we tell the Android OS that there is an authenticator for our Account-Type (defined in #1)
47 | If you provide multiple account types, you need to provide multiple authenticator xmls
48 |
49 | Here's an example. Make sure you're replacing the accountType with your own.
50 | ```xml
51 |
56 | ```
57 |
58 | ### 5. Gluing the Service and the Authenticator together.
59 |
60 | For that we need to add the Service we created in #3 into the manifest:
61 | Note that there's an additional `meta-data` tag which provides the xml we created in #4. If you have multiple xml's you need to provide additional Services.
62 |
63 | ```xml
64 |
67 |
68 |
69 |
70 |
73 |
74 | ```
75 |
76 | ### 6. Provide a LoginActivity
77 | Make sure you're extending it from the `AuthenticationActivity`
78 | ```kotlin
79 | class LoginActivity : AuthenticationActivity() {
80 | ...
81 | fun someLoginMethod() {
82 | val user: String
83 | val credential: String
84 | ...
85 | // do login work here and make sure, that you provide at least a user and a credential String
86 | ...
87 | Account account = createOrGetAccount(user)
88 | storeCredentials(
89 | account,
90 | credentialType, // AndroidCredentialType
91 | credential, // String as you get it from your Authenticator implementation
92 | mapOf(
93 | "some-key" to "some-value"
94 | )
95 | // store some additional userdata (optionally)
96 | storeUserData(account, "key_for_some_user_data", "some-userdata")
97 | // finishes the activity and set this account to the "current-active" one
98 | finalizeAuthentication(account)
99 | }
100 | ...
101 | }
102 | ```
103 | and add it also in the manifest using a special `intent-filter`. This `intent-filter` should
104 | as `action:name` contain the Action String you defined in #2
105 |
106 | ```xml
107 |
108 |
109 | ...
110 |
111 |
112 |
113 |
114 |
115 |
116 | ...
117 |
118 | ```
119 |
120 | ## Usage
121 | ### Create an Authenticator implementation
122 | For the Android Implementation you need to create an Authenticator:
123 | ```kotlin
124 | class YourAuthenticator
125 | : Authenticator() {
126 | ```
127 |
128 | There are 3 Methods required to implement:
129 | * The Owner-Type
130 | ```kotlin
131 | override fun getOwnerType(annotationOwnerType: Int): String
132 | ```
133 | This method provides us the ownerType that has been setup in the `@Authenticated` annotation.
134 | The value is optional! So if you don't need it, don't use it. A reason to use it could be, you need to use multiple ownerTypes on one endpoint.
135 |
136 | * The Credential-Type
137 | ```kotlin
138 | override fun getCredentialType(annotationCredentialType: Int): AndroidCredentialType {
139 | return AndroidCredentialType(
140 | "your.company.id.TOKEN_TYPE",
141 | setOf(
142 | "some optional",
143 | "keys",
144 | "which a credential provides"
145 | )
146 | )
147 | }
148 |
149 | ```
150 | Note that when getting an `AndroidCredential` it only contains the data, which is loaded
151 | using this set of optional keys.
152 |
153 | * Authenticate the request
154 | ```kotlin
155 | override fun authenticateRequest(request: Request, credentials: AndroidCredentials): Request {
156 | return request.newBuilder()
157 | .header("Authorization", "Bearer " + credentials.token)
158 | .build()
159 | }
160 | ```
161 |
162 | ### 5. Create your REST interface
163 | ```kotlin
164 | interface SomeAuthenticatedService {
165 | @GET("/some/path")
166 | fun someUnauthenticatedCall(): Call
167 |
168 | @Authenticated
169 | @GET("/some/other/path")
170 | fun someAuthenticatedCall(): Call
171 | }
172 | ```
173 |
174 | * Create the Retrofit object and instantiate it
175 | ```kotlin
176 | val authenticator: Authenticator = YourAuthenticator() // See Usage
177 | val baseRetrofit: Retrofit = Retrofit.Builder()
178 | .baseUrl("https://api.awesome.com/")
179 | // setup your retrofit as you wish...
180 | .addConverterFactory(GsonConverterFactory.create())
181 | .build()
182 |
183 | // Either this (which also works in plain java)
184 | val authRetrofit: Retrofit = RetroauthAndroid.setup(
185 | retrofit,
186 | application,
187 | authenticator
188 | )
189 | // OR this
190 | val authRetrofit = baseRetrofit.androidAuthentication(application, authenticator)
191 |
192 | // create your services
193 | val service = authRetrofit.create(SomeAuthenticatedService.class)
194 | // use them
195 | service.someAuthenticatedCall().execute()
196 | ```
197 | Another option is to create the retrofit instance completely yourself:
198 | ```kotlin
199 | // OR if you want to have full control:
200 | val authenticator: Authenticator = YourAuthenticator() // See Usage
201 | val ownerStorage: OwnerStorage = AndroidAccountManagerOwnerStorage(application)
202 | val credentialStorage: CredentialStorage = AndroidAccountManagerCredentialStorage(application)
203 | val retrofit: Retrofit = Retrofit.Builder()
204 | .baseUrl("https://api.awesome.com/")
205 | // setup your retrofit as you wish...
206 | .addConverterFactory(GsonConverterFactory.create())
207 | .client(
208 | OkHttpClient.Builder()
209 | .addInterceptor(CredentialInterceptor(authenticator, ownerStorage, credentialStorage))
210 | .build()
211 | )
212 | .build()
213 |
214 |
215 | // create your services
216 | val service = retrofit.create(SomeAuthenticatedService.class)
217 | // use them
218 | service.someAuthenticatedCall().execute()
219 | ```
220 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Apache License
2 | Version 2.0, January 2004
3 | http://www.apache.org/licenses/
4 |
5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6 |
7 | 1. Definitions.
8 |
9 | "License" shall mean the terms and conditions for use, reproduction,
10 | and distribution as defined by Sections 1 through 9 of this document.
11 |
12 | "Licensor" shall mean the copyright owner or entity authorized by
13 | the copyright owner that is granting the License.
14 |
15 | "Legal Entity" shall mean the union of the acting entity and all
16 | other entities that control, are controlled by, or are under common
17 | control with that entity. For the purposes of this definition,
18 | "control" means (i) the power, direct or indirect, to cause the
19 | direction or management of such entity, whether by contract or
20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
21 | outstanding shares, or (iii) beneficial ownership of such entity.
22 |
23 | "You" (or "Your") shall mean an individual or Legal Entity
24 | exercising permissions granted by this License.
25 |
26 | "Source" form shall mean the preferred form for making modifications,
27 | including but not limited to software source code, documentation
28 | source, and configuration files.
29 |
30 | "Object" form shall mean any form resulting from mechanical
31 | transformation or translation of a Source form, including but
32 | not limited to compiled object code, generated documentation,
33 | and conversions to other media types.
34 |
35 | "Work" shall mean the work of authorship, whether in Source or
36 | Object form, made available under the License, as indicated by a
37 | copyright notice that is included in or attached to the work
38 | (an example is provided in the Appendix below).
39 |
40 | "Derivative Works" shall mean any work, whether in Source or Object
41 | form, that is based on (or derived from) the Work and for which the
42 | editorial revisions, annotations, elaborations, or other modifications
43 | represent, as a whole, an original work of authorship. For the purposes
44 | of this License, Derivative Works shall not include works that remain
45 | separable from, or merely link (or bind by name) to the interfaces of,
46 | the Work and Derivative Works thereof.
47 |
48 | "Contribution" shall mean any work of authorship, including
49 | the original version of the Work and any modifications or additions
50 | to that Work or Derivative Works thereof, that is intentionally
51 | submitted to Licensor for inclusion in the Work by the copyright owner
52 | or by an individual or Legal Entity authorized to submit on behalf of
53 | the copyright owner. For the purposes of this definition, "submitted"
54 | means any form of electronic, verbal, or written communication sent
55 | to the Licensor or its representatives, including but not limited to
56 | communication on electronic mailing lists, source code control systems,
57 | and issue tracking systems that are managed by, or on behalf of, the
58 | Licensor for the purpose of discussing and improving the Work, but
59 | excluding communication that is conspicuously marked or otherwise
60 | designated in writing by the copyright owner as "Not a Contribution."
61 |
62 | "Contributor" shall mean Licensor and any individual or Legal Entity
63 | on behalf of whom a Contribution has been received by Licensor and
64 | subsequently incorporated within the Work.
65 |
66 | 2. Grant of Copyright License. Subject to the terms and conditions of
67 | this License, each Contributor hereby grants to You a perpetual,
68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69 | copyright license to reproduce, prepare Derivative Works of,
70 | publicly display, publicly perform, sublicense, and distribute the
71 | Work and such Derivative Works in Source or Object form.
72 |
73 | 3. Grant of Patent License. Subject to the terms and conditions of
74 | this License, each Contributor hereby grants to You a perpetual,
75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76 | (except as stated in this section) patent license to make, have made,
77 | use, offer to sell, sell, import, and otherwise transfer the Work,
78 | where such license applies only to those patent claims licensable
79 | by such Contributor that are necessarily infringed by their
80 | Contribution(s) alone or by combination of their Contribution(s)
81 | with the Work to which such Contribution(s) was submitted. If You
82 | institute patent litigation against any entity (including a
83 | cross-claim or counterclaim in a lawsuit) alleging that the Work
84 | or a Contribution incorporated within the Work constitutes direct
85 | or contributory patent infringement, then any patent licenses
86 | granted to You under this License for that Work shall terminate
87 | as of the date such litigation is filed.
88 |
89 | 4. Redistribution. You may reproduce and distribute copies of the
90 | Work or Derivative Works thereof in any medium, with or without
91 | modifications, and in Source or Object form, provided that You
92 | meet the following conditions:
93 |
94 | (a) You must give any other recipients of the Work or
95 | Derivative Works a copy of this License; and
96 |
97 | (b) You must cause any modified files to carry prominent notices
98 | stating that You changed the files; and
99 |
100 | (c) You must retain, in the Source form of any Derivative Works
101 | that You distribute, all copyright, patent, trademark, and
102 | attribution notices from the Source form of the Work,
103 | excluding those notices that do not pertain to any part of
104 | the Derivative Works; and
105 |
106 | (d) If the Work includes a "NOTICE" text file as part of its
107 | distribution, then any Derivative Works that You distribute must
108 | include a readable copy of the attribution notices contained
109 | within such NOTICE file, excluding those notices that do not
110 | pertain to any part of the Derivative Works, in at least one
111 | of the following places: within a NOTICE text file distributed
112 | as part of the Derivative Works; within the Source form or
113 | documentation, if provided along with the Derivative Works; or,
114 | within a display generated by the Derivative Works, if and
115 | wherever such third-party notices normally appear. The contents
116 | of the NOTICE file are for informational purposes only and
117 | do not modify the License. You may add Your own attribution
118 | notices within Derivative Works that You distribute, alongside
119 | or as an addendum to the NOTICE text from the Work, provided
120 | that such additional attribution notices cannot be construed
121 | as modifying the License.
122 |
123 | You may add Your own copyright statement to Your modifications and
124 | may provide additional or different license terms and conditions
125 | for use, reproduction, or distribution of Your modifications, or
126 | for any such Derivative Works as a whole, provided Your use,
127 | reproduction, and distribution of the Work otherwise complies with
128 | the conditions stated in this License.
129 |
130 | 5. Submission of Contributions. Unless You explicitly state otherwise,
131 | any Contribution intentionally submitted for inclusion in the Work
132 | by You to the Licensor shall be under the terms and conditions of
133 | this License, without any additional terms or conditions.
134 | Notwithstanding the above, nothing herein shall supersede or modify
135 | the terms of any separate license agreement you may have executed
136 | with Licensor regarding such Contributions.
137 |
138 | 6. Trademarks. This License does not grant permission to use the trade
139 | names, trademarks, service marks, or product names of the Licensor,
140 | except as required for reasonable and customary use in describing the
141 | origin of the Work and reproducing the content of the NOTICE file.
142 |
143 | 7. Disclaimer of Warranty. Unless required by applicable law or
144 | agreed to in writing, Licensor provides the Work (and each
145 | Contributor provides its Contributions) on an "AS IS" BASIS,
146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147 | implied, including, without limitation, any warranties or conditions
148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149 | PARTICULAR PURPOSE. You are solely responsible for determining the
150 | appropriateness of using or redistributing the Work and assume any
151 | risks associated with Your exercise of permissions under this License.
152 |
153 | 8. Limitation of Liability. In no event and under no legal theory,
154 | whether in tort (including negligence), contract, or otherwise,
155 | unless required by applicable law (such as deliberate and grossly
156 | negligent acts) or agreed to in writing, shall any Contributor be
157 | liable to You for damages, including any direct, indirect, special,
158 | incidental, or consequential damages of any character arising as a
159 | result of this License or out of the use or inability to use the
160 | Work (including but not limited to damages for loss of goodwill,
161 | work stoppage, computer failure or malfunction, or any and all
162 | other commercial damages or losses), even if such Contributor
163 | has been advised of the possibility of such damages.
164 |
165 | 9. Accepting Warranty or Additional Liability. While redistributing
166 | the Work or Derivative Works thereof, You may choose to offer,
167 | and charge a fee for, acceptance of support, warranty, indemnity,
168 | or other liability obligations and/or rights consistent with this
169 | License. However, in accepting such obligations, You may act only
170 | on Your own behalf and on Your sole responsibility, not on behalf
171 | of any other Contributor, and only if You agree to indemnify,
172 | defend, and hold each Contributor harmless for any liability
173 | incurred by, or claims asserted against, such Contributor by reason
174 | of your accepting any such warranty or additional liability.
175 |
176 | END OF TERMS AND CONDITIONS
177 |
178 | APPENDIX: How to apply the Apache License to your work.
179 |
180 | To apply the Apache License to your work, attach the following
181 | boilerplate notice, with the fields enclosed by brackets "{}"
182 | replaced with your own identifying information. (Don't include
183 | the brackets!) The text should be enclosed in the appropriate
184 | comment syntax for the file format. We also recommend that a
185 | file or class name and description of purpose be included on the
186 | same "printed page" as the copyright notice for easier
187 | identification within third-party archives.
188 |
189 | Copyright 2016 André Tietz
190 |
191 | Licensed under the Apache License, Version 2.0 (the "License");
192 | you may not use this file except in compliance with the License.
193 | You may obtain a copy of the License at
194 |
195 | http://www.apache.org/licenses/LICENSE-2.0
196 |
197 | Unless required by applicable law or agreed to in writing, software
198 | distributed under the License is distributed on an "AS IS" BASIS,
199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200 | See the License for the specific language governing permissions and
201 | limitations under the License.
202 |
203 |
--------------------------------------------------------------------------------
/retroauth/src/test/java/com/andretietz/retroauth/CredentialInterceptorTest.kt:
--------------------------------------------------------------------------------
1 | package com.andretietz.retroauth
2 |
3 | import com.nhaarman.mockitokotlin2.any
4 | import com.nhaarman.mockitokotlin2.doAnswer
5 | import com.nhaarman.mockitokotlin2.doReturn
6 | import com.nhaarman.mockitokotlin2.mock
7 | import com.nhaarman.mockitokotlin2.never
8 | import com.nhaarman.mockitokotlin2.times
9 | import com.nhaarman.mockitokotlin2.verify
10 | import kotlinx.coroutines.Dispatchers
11 | import kotlinx.coroutines.ExperimentalCoroutinesApi
12 | import kotlinx.coroutines.launch
13 | import kotlinx.coroutines.runBlocking
14 | import okhttp3.Dispatcher
15 | import okhttp3.HttpUrl
16 | import okhttp3.Interceptor
17 | import okhttp3.OkHttpClient
18 | import okhttp3.Request
19 | import okhttp3.Response
20 | import okhttp3.mockwebserver.MockResponse
21 | import org.assertj.core.api.Assertions.assertThat
22 | import org.assertj.core.api.Assertions.fail
23 | import org.junit.Rule
24 | import org.junit.Test
25 | import org.mockito.ArgumentMatchers.anyInt
26 | import org.mockito.ArgumentMatchers.anyString
27 | import retrofit2.Retrofit
28 | import retrofit2.converter.gson.GsonConverterFactory
29 | import retrofit2.http.GET
30 |
31 | @ExperimentalCoroutinesApi
32 | @Suppress("Detekt.LargeClass")
33 | class CredentialInterceptorTest {
34 | @get:Rule
35 | internal val serverRule = MockServerRule()
36 |
37 | @Test
38 | @ExperimentalCoroutinesApi
39 | fun `unauthenticated call with successful response`() = runBlocking {
40 | serverRule.server.enqueue(MockResponse().setBody("{\"data\": \"testdata\"}"))
41 | val ownerStorage = mock>()
42 | val credentialStorage = mock>()
43 | val authenticator = mock>()
44 | val interceptor = CredentialInterceptor(
45 | authenticator,
46 | ownerStorage,
47 | credentialStorage
48 | )
49 |
50 | val api = createSomeApi(serverRule.server.url("/"), interceptor)
51 |
52 | val data = api.someCall()
53 |
54 | assertThat(data).isEqualTo(Data("testdata"))
55 |
56 | verify(authenticator, never()).authenticateRequest(any(), any())
57 | Unit
58 | }
59 |
60 | @Test
61 | fun `authenticated call, no owner existing`() = runBlocking {
62 | // setup ownerstore, without any owner existing
63 | val ownerStorage = mock> {
64 | on { getActiveOwner() } doReturn null as String?
65 | on { getOwners() } doReturn emptyList()
66 | }
67 |
68 | val credentialStorage = mock>()
69 | val authenticator = mock> {
70 | on { getCredentialType(any()) } doReturn CREDENTIAL_TYPE
71 | }
72 |
73 | val interceptor = CredentialInterceptor(
74 | authenticator,
75 | ownerStorage,
76 | credentialStorage
77 | )
78 |
79 | val api = createSomeApi(serverRule.server.url("/"), interceptor)
80 |
81 | try {
82 | api.someAuthenticatedCall()
83 | } catch (error: AuthenticationRequiredException) {
84 | // This is an expected error!
85 | }
86 | // FIXME
87 | //verify(ownerStorage, times(1)).createOwner(anyString(), any())
88 | Unit
89 | }
90 |
91 | @Test
92 | fun `authenticated call with successful response`() = runBlocking {
93 | serverRule.server.enqueue(MockResponse().setBody("{\"data\": \"testdata\"}"))
94 |
95 | val ownerStorage = mock> {
96 | on { getActiveOwner() } doReturn "owner"
97 | }
98 | val credentialStorage = mock> {
99 | on {
100 | getCredentials(any(), any())
101 | } doReturn Credentials("credential")
102 | }
103 |
104 | val authenticator = mock> {
105 | on { getCredentialType(any()) } doReturn CREDENTIAL_TYPE
106 | on { isCredentialValid(any()) } doReturn true
107 | on { authenticateRequest(any(), any()) } doAnswer { invocationOnMock ->
108 | (invocationOnMock.arguments[0] as Request)
109 | .newBuilder()
110 | .addHeader("auth-header", "auth-token")
111 | .build()
112 | }
113 | }
114 |
115 | val interceptor = CredentialInterceptor(
116 | authenticator,
117 | ownerStorage,
118 | credentialStorage
119 | )
120 |
121 | val api = createSomeApi(serverRule.server.url("/"), interceptor)
122 |
123 | val data = api.someAuthenticatedCall()
124 |
125 | assertThat(data).isEqualTo(Data("testdata"))
126 | verify(authenticator, times(1)).authenticateRequest(any(), any())
127 | assertThat(serverRule.server.takeRequest().headers["auth-header"]).isEqualTo("auth-token")
128 | Unit
129 | }
130 |
131 | @Test
132 | fun `Invalid token, refreshes token`() = runBlocking {
133 | serverRule.server.enqueue(MockResponse().setBody("{\"data\": \"testdata\"}"))
134 |
135 | val ownerStorage = mock> {
136 | on { getActiveOwner() } doReturn "owner"
137 | }
138 | val credentialStorage = mock> {
139 | on {
140 | getCredentials(any(), any())
141 | } doReturn Credentials("credential")
142 | }
143 | val authenticator = mock> {
144 | on { getCredentialType(any()) } doReturn CREDENTIAL_TYPE
145 | on { isCredentialValid(any()) } doReturn false // token is invalid
146 | on { authenticateRequest(any(), any()) } doAnswer { invocationOnMock ->
147 | (invocationOnMock.arguments[0] as Request)
148 | .newBuilder()
149 | .addHeader("auth-header", "auth-token")
150 | .build()
151 | }
152 | on {
153 | refreshCredentials(anyString(), any(), any())
154 | } doReturn Credentials("credential")
155 | }
156 |
157 | val interceptor = CredentialInterceptor(
158 | authenticator,
159 | ownerStorage,
160 | credentialStorage
161 | )
162 |
163 | val api = createSomeApi(serverRule.server.url("/"), interceptor)
164 |
165 | val data = api.someAuthenticatedCall()
166 |
167 | assertThat(data).isEqualTo(Data("testdata"))
168 | verify(authenticator, times(1)).authenticateRequest(any(), any())
169 | assertThat(serverRule.server.takeRequest().headers["auth-header"]).isEqualTo("auth-token")
170 | // refresh credentials successfully
171 | verify(authenticator, times(1)).refreshCredentials(anyString(), any(), any())
172 | // store new token
173 | verify(credentialStorage, times(1)).storeCredentials(anyString(), any(), any())
174 | }
175 |
176 | @Test
177 | fun `Invalid token, refresh token fails`() = runBlocking {
178 | serverRule.server.enqueue(MockResponse().setBody("{\"data\": \"testdata\"}"))
179 |
180 | val ownerStorage = mock> {
181 | on { getActiveOwner() } doReturn "owner"
182 | }
183 | val credentialStorage = mock> {
184 | on {
185 | getCredentials(any(), any())
186 | } doReturn Credentials("credential")
187 | }
188 | val authenticator = mock> {
189 | on { getCredentialType(any()) } doReturn CREDENTIAL_TYPE
190 | on { isCredentialValid(any()) } doReturn false // token is invalid
191 | on { authenticateRequest(any(), any()) } doAnswer { invocationOnMock ->
192 | (invocationOnMock.arguments[0] as Request)
193 | .newBuilder()
194 | .addHeader("auth-header", "auth-token")
195 | .build()
196 | }
197 | on {
198 | refreshCredentials(anyString(), any(), any())
199 | } doReturn null
200 | }
201 |
202 | val interceptor = CredentialInterceptor(
203 | authenticator,
204 | ownerStorage,
205 | credentialStorage
206 | )
207 |
208 | val api = createSomeApi(serverRule.server.url("/"), interceptor)
209 |
210 | val data = api.someAuthenticatedCall()
211 |
212 | assertThat(data).isEqualTo(Data("testdata"))
213 | verify(authenticator, times(1)).authenticateRequest(any(), any())
214 | assertThat(serverRule.server.takeRequest().headers["auth-header"]).isEqualTo("auth-token")
215 | // old credentials removed, ONCE
216 | verify(credentialStorage, times(1)).removeCredentials(anyString(), any())
217 | // refresh credentials successfully
218 | verify(authenticator, times(1)).refreshCredentials(anyString(), any(), any())
219 |
220 | Unit
221 | }
222 |
223 | @Test
224 | fun `Refresh required after failing call`() = runBlocking {
225 | serverRule.server.enqueue(MockResponse().setResponseCode(401))
226 | serverRule.server.enqueue(MockResponse().setBody("{\"data\": \"testdata\"}"))
227 |
228 | val ownerStorage = mock> {
229 | on { getActiveOwner() } doReturn "owner"
230 | }
231 | val credentialStorage = mock> {
232 | on {
233 | getCredentials(any(), any())
234 | } doReturn Credentials("credential")
235 | }
236 | val authenticator = mock> {
237 | on { getCredentialType(any()) } doReturn CREDENTIAL_TYPE
238 | on { isCredentialValid(any()) } doReturn true
239 | on { authenticateRequest(any(), any()) } doAnswer { invocationOnMock ->
240 | (invocationOnMock.arguments[0] as Request)
241 | .newBuilder()
242 | .addHeader("auth-header", "auth-token")
243 | .build()
244 | }
245 | on {
246 | refreshCredentials(anyString(), any(), any())
247 | } doReturn Credentials("credential")
248 | on {
249 | refreshRequired(anyInt(), any())
250 | } doAnswer { invocationOnMock ->
251 | (invocationOnMock.arguments[1] as Response).code != 200
252 | }
253 | }
254 |
255 | val interceptor = CredentialInterceptor(
256 | authenticator,
257 | ownerStorage,
258 | credentialStorage
259 | )
260 |
261 | val api = createSomeApi(serverRule.server.url("/"), interceptor)
262 |
263 | val data = api.someAuthenticatedCall()
264 |
265 | assertThat(data).isEqualTo(Data("testdata"))
266 | verify(authenticator, times(2)).authenticateRequest(any(), any())
267 | assertThat(serverRule.server.takeRequest().headers["auth-header"]).isEqualTo("auth-token")
268 |
269 | Unit
270 | }
271 |
272 | /**
273 | * This test has been added in order to verify that multiple requests at the same time
274 | * don't trigger multiple refreshes. This has been a big issue when using this in production.
275 | * Intentionally I am using an absurd high number of simultaneous requests here to test the
276 | * robustness.
277 | *
278 | * @see [EXTREME_REQUEST_COUNT] the amount of requests for this test case
279 | */
280 | @Test
281 | fun `Invalid token, refreshes token, EXTREME_REQUEST_COUNT calls`() = runBlocking {
282 | val ownerStorage = mock> {
283 | on { getActiveOwner() } doReturn "owner"
284 | }
285 | var credential = Credentials("old-credential")
286 | val credentialStorage = mock> {
287 | on {
288 | getCredentials(any(), any())
289 | } doAnswer { credential }
290 | }
291 | val authenticator = mock> {
292 | on { getCredentialType(any()) } doReturn CREDENTIAL_TYPE
293 | on { isCredentialValid(any()) } doAnswer { invocationOnMock ->
294 | (invocationOnMock.arguments[0] as Credentials).token == "credential"
295 | }
296 | on { authenticateRequest(any(), any()) } doAnswer { invocationOnMock ->
297 | (invocationOnMock.arguments[0] as Request)
298 | .newBuilder()
299 | .addHeader("auth-header", "auth-token")
300 | .build()
301 | }
302 | on {
303 | refreshCredentials(anyString(), any(), any())
304 | } doAnswer {
305 | Thread.sleep(200)
306 | credential = Credentials("credential")
307 | credential
308 | }
309 | }
310 |
311 | val interceptor = CredentialInterceptor(
312 | authenticator,
313 | ownerStorage,
314 | credentialStorage
315 | )
316 |
317 | val api = createSomeApi(serverRule.server.url("/"), interceptor)
318 |
319 | launch {
320 | repeat(EXTREME_REQUEST_COUNT) {
321 | launch {
322 | serverRule.server.enqueue(MockResponse().setBody("{\"data\": \"testdata\"}"))
323 | api.someAuthenticatedCall()
324 | }
325 | }
326 | }.join()
327 |
328 | // refresh credentials successfully, ONCE
329 | verify(authenticator, times(1)).refreshCredentials(anyString(), any(), any())
330 | // store new token, ONCE
331 | verify(credentialStorage, times(1)).storeCredentials(anyString(), any(), any())
332 | // authenticate request -> 200 times (or how much range includes)
333 | verify(authenticator, times(EXTREME_REQUEST_COUNT)).authenticateRequest(any(), any())
334 |
335 | Unit
336 | }
337 |
338 | /**
339 | * Same problem as above only that in here we verify that if the refresh throws an error,
340 | * all requests waiting to do the refresh as well are skipped and rethrow the error
341 | * produced by the first one.
342 | */
343 | @Test
344 | fun `Invalid token, refreshes token an error occurs, 200 calls`() = runBlocking {
345 | val ownerStorage = mock> {
346 | on { getActiveOwner() } doReturn "owner"
347 | }
348 | val credentialStorage = mock> {
349 | on {
350 | getCredentials(any(), any())
351 | } doReturn Credentials("old-credential")
352 | }
353 | val authenticator = mock> {
354 | on { getCredentialType(any()) } doReturn CREDENTIAL_TYPE
355 | on { isCredentialValid(any()) } doReturn false
356 | on {
357 | refreshCredentials(anyString(), any(), any())
358 | } doAnswer {
359 | // with this we make sure that the first request within the lock
360 | // takes a bit longer, so that all other 199 requests queue up before the first call
361 | // unlocks the mutex in the CredentialInterceptor
362 | Thread.sleep(500)
363 | error("some error was thrown")
364 | }
365 | }
366 |
367 | val interceptor = CredentialInterceptor(
368 | authenticator,
369 | ownerStorage,
370 | credentialStorage
371 | )
372 | val api = createSomeApi(serverRule.server.url("/"), interceptor)
373 |
374 | launch {
375 | repeat(200) {
376 | serverRule.server.enqueue(MockResponse().setBody("{\"data\": \"testdata\"}"))
377 | launch(Dispatchers.IO) {
378 | try {
379 | api.someAuthenticatedCall()
380 | fail("This part of the code should never be reached")
381 | } catch (error: Throwable) {
382 | assertThat(error.message).contains("some error was thrown")
383 | }
384 | }
385 | }
386 | }.join()
387 |
388 | // get the active owner ONCE
389 | verify(ownerStorage, times(1)).getActiveOwner()
390 | // try to refresh credentials, ONCE
391 | // this is throwing an exception
392 | verify(authenticator, times(1)).refreshCredentials(anyString(), any(), any())
393 | // store new token, ONCE
394 | verify(credentialStorage, never()).storeCredentials(anyString(), any(), any())
395 | // authenticate request -> never
396 | verify(authenticator, never()).authenticateRequest(any(), any())
397 |
398 | Unit
399 | }
400 |
401 | companion object {
402 | private const val CREDENTIAL_TYPE = "credential_type"
403 |
404 | private const val EXTREME_REQUEST_COUNT = 200
405 |
406 | private fun createSomeApi(url: HttpUrl, interceptor: Interceptor): SomeApi {
407 | return Retrofit.Builder()
408 | .baseUrl(url)
409 | .addConverterFactory(GsonConverterFactory.create())
410 | .client(
411 | OkHttpClient.Builder()
412 | /**
413 | * Since okhttp supports 5 connections at a time. in order to test what we want to test we need
414 | * to increase that limit.
415 | * https://square.github.io/okhttp/4.x/okhttp/okhttp3/-dispatcher/
416 | * https://stackoverflow.com/questions/42299791/okhttpclient-limit-number-of-connections
417 | */
418 | .dispatcher(Dispatcher().also {
419 | it.maxRequests = EXTREME_REQUEST_COUNT
420 | it.maxRequestsPerHost = EXTREME_REQUEST_COUNT
421 | })
422 | .addInterceptor(interceptor)
423 | .build()
424 | )
425 | .build().create(SomeApi::class.java)
426 | }
427 | }
428 | }
429 |
430 | data class Data(val data: String)
431 |
432 | interface SomeApi {
433 | @GET("/some/path")
434 | suspend fun someCall(): Data
435 |
436 | @Authenticated
437 | @GET("/some/other/path")
438 | suspend fun someAuthenticatedCall(): Data
439 | }
440 |
--------------------------------------------------------------------------------