Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Handle session expiration time issue #89. #94

Open
wants to merge 21 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/pull-request-jobs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,7 @@ jobs:
run: ./gradlew testReleaseUnit
- name: Run Core Unit Tests
run: ./gradlew core:test
- name: Run GsonBuilder Unit Tests
run: ./gradlew oauthgson:test
- name: Run MoshiBuilder Unit Tests
run: ./gradlew oauthmoshi:test
82 changes: 52 additions & 30 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ Details can be found [here](https://oauth.net/2/ "here")

Blog post explaining it [here](http://www.bubblecode.net/en/2016/01/22/understanding-oauth2/#targetText=OAuth2%20is%2C%20you%20guessed%20it,access%20on%20its%20own%20behalf. "here")

##### Checking ExpiresIn

Usually we also get back an `expires_in` response after receiving the Tokens. This signals to us in what time the token will expire.
Optimally we should check this time and refresh our token before sending out the actual Data Request. This additional implementation was reported as Issue #89 and should be ready by setting `TokenExpirationStorage`.

### So how does this library help you with that?

Expand All @@ -50,10 +54,10 @@ Error Cases:

- When the refresh-token is no longer valid or expired, (returned by the server while trying to refresh token) then you will receive a callback with session expiration and your request will fail with 401.
- When the refresh-token request failed 3 times, then your request will fail with 401. And should be handled as normal network error
To see the behaviour in action may refer to [com.halcyonmobile.core.AuthenticationTest]


To see the behaviour in action may refer to [com.halcyonmobile.core.AuthenticationWithoutTokenStorageTest]

## Setup
- This contains how you can use this library
- *Latest version:* ![Latest release](https://img.shields.io/github/v/release/halcyonmobile/retrofit-oauth2-helper)
Expand Down Expand Up @@ -91,20 +95,20 @@ allprojects {
// https://docs.github.com/en/github/authenticating-to-github/keeping-your-account-and-data-secure/creating-a-personal-access-token
}
}
}
}
```

Note: you only need one maven declaration with "halcyonmobile/{specific}", every other package will be accessible.

### General Idea

- You will see a "core" and "app" module, this is specific to our architecture, the core means a module which does the business logic, network requests etc, it's a java module while the app module is handling the ui and other platform specific implementation details.
- The idea is that in your core module you will do the configuration and get the created retrofit instances
- The idea is that in your core module you will do the configuration and get the created retrofit instances
so it will depend on either the oauth or oauthkoin, oauthmoshi or some other variant
- However the core module won't be able to contain all the needed dependencies, because of that you should use the
- However the core module won't be able to contain all the needed dependencies, because of that you should use the
oauthdependencies in your app module so you can provide the storage and session expiration handler.
- Optionally you can use the oauthstorage in your app to reduce the shared preferences boilerplate.
- Optionally you can use the oauthadaptergenerator in your core where you define the refresh token retrofit service, so
- Optionally you can use the oauthadaptergenerator in your core where you define the refresh token retrofit service, so
you don't need to write your adapter if it's simple.
- Note: oauth-moshi, oauth-koin do not need adapters, they already contain a refresh service.

Expand All @@ -122,7 +126,8 @@ fun createNetworkModules(
clientId: String,
baseUrl: String,
provideAuthenticationLocalStorage: Scope.() -> AuthenticationLocalStorage,
provideSessionExpiredEventHandler: Scope.() -> SessionExpiredEventHandler
provideSessionExpiredEventHandler: Scope.() -> SessionExpiredEventHandler,
provideTokenExpirationStorage: Scope.() -> TokenExpirationStorage = { NeverExpiredTokenExpirationStorage() } // optional
): List<Module> {
return listOf(
module {
Expand All @@ -131,13 +136,15 @@ fun createNetworkModules(
factory { ExampleRemoteSource(get(), get()) }
},
module {
single { provideTokenExpirationStorage() } // optional
single { provideAuthenticationLocalStorage() }
single { provideSessionExpiredEventHandler() }
single {
OauthRetrofitWithMoshiContainerBuilder(
clientId = clientId,
authenticationLocalStorage = provideAuthenticationLocalStorage(),
sessionExpiredEventHandler = provideSessionExpiredEventHandler()
authenticationLocalStorage = get(),
sessionExpiredEventHandler = get(),
tokenExpirationStorage = get() // optional
)
.configureRetrofit {
baseUrl(baseUrl)
Expand Down Expand Up @@ -174,7 +181,8 @@ fun createAllModules(baseUrl: String, clientId: String): List<Module> {
baseUrl = baseUrl,
clientId = clientId,
provideAuthenticationLocalStorage = { get<SharedPreferenceManager>() },
provideSessionExpiredEventHandler = { get<SessionExpiredEventHandlerImpl>() }
provideSessionExpiredEventHandler = { get<SessionExpiredEventHandlerImpl>() },
provideTokenExpirationStorage = { get<SharedPreferenceManager>() } // optional
))
}
```
Expand Down Expand Up @@ -213,6 +221,16 @@ How to handle the exception:
}
```


#### I want to take into account the expires_in response, what should I do?

You have to do 3 things:
- Parse the optional `expiresInSeconds` field of the `SessionDataResponse`
- Save that field into the `TokenExpirationStorage` just like you should save your tokens into `AuthenticationLocalStorage`
- Give the builder a `TokenExpirationStorage` implementation, you may use `CombinedSharedPreferencesStorage`.

If you have done all that, whenever a request is sent out the current time will be checked agains `TokenExpirationStorage.accessTokenExpiresAt` and if it expired, then the RefreshToken Request will be run before sending out the Data Request. This mechanizm also synchronized so 2 request won't send 2 RefreshToken Requests, but wait for one and update their headers.

### Oauth-gson setup
If you are using moshi and some other dependency injection framework than koin what you need to do is add the dependency in your build.gradle of your core module
Note: still the example will be using koin, to adapt to your DI is your responsibility.
Expand All @@ -227,7 +245,8 @@ fun createNetworkModules(
clientId: String,
baseUrl: String,
provideAuthenticationLocalStorage: Scope.() -> AuthenticationLocalStorage,
provideSessionExpiredEventHandler: Scope.() -> SessionExpiredEventHandler
provideSessionExpiredEventHandler: Scope.() -> SessionExpiredEventHandler,
provideTokenExpirationStorage: Scope.() -> TokenExpirationStorage = { NeverExpiredTokenExpirationStorage() } // optional
): List<Module> {
return listOf(
module {
Expand All @@ -236,13 +255,15 @@ fun createNetworkModules(
factory { ExampleRemoteSource(get(), get()) }
},
module {
single { provideTokenExpirationStorage() } // optional
single { provideAuthenticationLocalStorage() }
single { provideSessionExpiredEventHandler() }
single {
OauthRetrofitWithGsonContainerBuilder(
clientId = clientId,
authenticationLocalStorage = provideAuthenticationLocalStorage(),
sessionExpiredEventHandler = provideSessionExpiredEventHandler()
authenticationLocalStorage = get(),
sessionExpiredEventHandler = get(),
tokenExpirationStorage = get() // optional
)
.configureRetrofit {
baseUrl(baseUrl)
Expand Down Expand Up @@ -276,7 +297,8 @@ fun createNetworkModules(
clientId: String,
baseUrl: String,
provideAuthenticationLocalStorage: Scope.() -> AuthenticationLocalStorage,
provideSessionExpiredEventHandler: Scope.() -> SessionExpiredEventHandler
provideSessionExpiredEventHandler: Scope.() -> SessionExpiredEventHandler,
provideTokenExpirationStorage: Scope.() -> TokenExpirationStorage = { NeverExpiredTokenExpirationStorage() } // optional
): List<Module> {
return listOf(
// your own custom module,
Expand All @@ -290,6 +312,7 @@ fun createNetworkModules(
clientId = clientId,
provideSessionExpiredEventHandler = provideSessionExpiredEventHandler,
provideAuthenticationLocalStorage = provideAuthenticationLocalStorage,
provideTokenExpirationStorage = provideTokenExpirationStorage,
configureRetrofit = {
it.baseUrl(baseUrl)
}
Expand All @@ -303,7 +326,7 @@ Same as Oauth-moshi setup, please check that one out.

### Using Only oauth setup
If none of the other setups are applicable, you are not using moshi then you can fallback to this, however i would suggest to add a new module with your implementation instead.
Note: still the example will be using koin and moshi, to adapt to your DI is your responsibility.
Note: still the example will be using koin and moshi, to adapt to your DI is your responsibility.

#### core module

Expand Down Expand Up @@ -361,7 +384,8 @@ fun createNetworkModules(
clientId: String,
baseUrl: String,
provideAuthenticationLocalStorage: Scope.() -> AuthenticationLocalStorage,
provideSessionExpiredEventHandler: Scope.() -> SessionExpiredEventHandler
provideSessionExpiredEventHandler: Scope.() -> SessionExpiredEventHandler,
provideTokenExpirationStorage: Scope.() -> TokenExpirationStorage = { NeverExpiredTokenExpirationStorage() } // optional
): List<Module> {
return listOf(
module {
Expand All @@ -370,15 +394,17 @@ fun createNetworkModules(
factory { ExampleRemoteSource(get(), get()) }
},
module {
single { provideTokenExpirationStorage() } // optional
single { provideAuthenticationLocalStorage() }
single { provideSessionExpiredEventHandler() }
single { Moshi.Builder().build() }
single {
OauthRetrofitContainerBuilder(
clientId = clientId,
refreshServiceClass = RefreshTokenService::class,
authenticationLocalStorage = provideAuthenticationLocalStorage(),
sessionExpiredEventHandler = provideSessionExpiredEventHandler(),
authenticationLocalStorage = get(),
sessionExpiredEventHandler = get(),
tokenExpirationStorage = get() // optional
adapter = RefreshTokenServiceAuthenticationServiceAdapter()
)
.configureRetrofit {
Expand Down Expand Up @@ -427,20 +453,13 @@ The following section describes current modules and the preferred content & usag
- An example of a core layer which used for networking and how it needs to configure the retrofit instances

### oauth
- The base implementation of the extension.
- The base implementation of the extension.

### oauthdependencies
- Dependencies which has to come from the outside (app module), but has no relation to retrofit

### oauthkoin
- this module contains configuration functions which create the koin modules you can simply add to your startKoin method
- uses koin 1.0.2

### oauthdagger (PLANED)
- WIP

### oauthstorage
- A persistent storage for session based on SharedPreferences. It's implemented in a way that can be used separately or
- A persistent storage for session based on SharedPreferences. It's implemented in a way that can be used separately or
with an existing SharedPreferencesManager

### oauthsecurestorage
Expand All @@ -467,12 +486,15 @@ Note: it implements the oauthparsing

### oauthmoshkoin
- this module contains a configuration function which create the koin module, which you can simply add to your other modules
- uses koin 2.0.1
- uses koin 3.1.5

### oauthgson
- An extension of the base implementation which includes gson and the service with default parameters
Here you don't need to write your own service and parsing, however you are still able to configure the service and parsing.

### dependenciestest
- Module intended only for testing. Contains Base test cases for Storage interface and other oauthdependencies interfaces.

Note: it implements the oauthparsing

<h1 id="license">License :page_facing_up:</h1>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ class ExampleApplication : Application() {

override fun onCreate() {
super.onCreate()
startKoin{
startKoin {
androidContext(this@ExampleApplication)
modules(createModules())
}
Expand All @@ -36,14 +36,16 @@ class ExampleApplication : Application() {
fun createModules(): List<Module> =
listOf(
module {
single { SharedPreferencesManager(get(), encrypted = false, compat = true) }
single { SharedPreferencesManager(context = get(), type = SharedPreferencesManager.Type.WITH_TOKEN_EXPIRATION, variant = SharedPreferencesManager.Variant.COMPAT) }
}
)
.plus(
createNetworkModules(
clientId = "CLIENT-ID",
baseUrl = "https://google.com/",
provideAuthenticationLocalStorage = { get<SharedPreferencesManager>() },
provideSessionExpiredEventHandler = { SessionExpiredEventHandlerImpl(get()) }
))
clientId = "CLIENT-ID",
baseUrl = "https://google.com/",
provideAuthenticationLocalStorage = { get<SharedPreferencesManager>() },
provideSessionExpiredEventHandler = { SessionExpiredEventHandlerImpl(get()) },
provideTokenExpirationStorage = { get<SharedPreferencesManager>() },
)
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,13 @@ import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import com.halcyonmobile.core.ExampleRemoteSource
import com.halcyonmobile.oauth.dependencies.AuthenticationLocalStorage
import com.halcyonmobile.oauth.dependencies.TokenExpirationStorage
import org.koin.android.ext.android.inject

class MainActivity : AppCompatActivity() {

private val authenticationLocalStorage by inject<AuthenticationLocalStorage>()
private val tokenExpirationStorage by inject<TokenExpirationStorage>()
private val exampleRemoteSource by inject<ExampleRemoteSource>()

override fun onCreate(savedInstanceState: Bundle?) {
Expand All @@ -35,6 +37,7 @@ class MainActivity : AppCompatActivity() {
authenticationLocalStorage.accessToken = "accessToken"
authenticationLocalStorage.refreshToken = "accessToken"
authenticationLocalStorage.userId = "1234"
tokenExpirationStorage.accessTokenExpiresAt = Long.MIN_VALUE
exampleRemoteSource.nonsession(object : ExampleRemoteSource.Callback {
override fun success() = runSessionService()
override fun error() = runSessionService()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import com.halcyonmobile.oauth.dependencies.SessionExpiredEventHandler
class SessionExpiredEventHandlerImpl(private val context: Context) : SessionExpiredEventHandler {
override fun onSessionExpired() {
Handler(Looper.getMainLooper()).post {
System.err.println("SESSION EXPIRATION!")
// todo navigate to sign in
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,22 +18,61 @@ package com.halcyonmobile.oauth_retrofit

import android.content.Context
import com.halcyonmobile.oauth.dependencies.AuthenticationLocalStorage
import com.halcyonmobile.oauth.dependencies.TokenExpirationStorage
import com.halcyonmobile.oauthsecurestorage.AuthenticationSecureSharedPreferencesStorage
import com.halcyonmobile.oauthsecurestorage.CombinedSecureSharedPreferencesStorage
import com.halcyonmobile.oauthsecurestoragecompat.AuthenticationSecureSharedPreferencesStorageCompat
import com.halcyonmobile.oauthsecurestoragecompat.CombinedSecureSharedPreferencesStorageCompat
import com.halcyonmobile.oauthstorage.AuthenticationSharedPreferencesStorage
import com.halcyonmobile.oauthstorage.CombinedSharedPreferencesStorage

class SharedPreferencesManager private constructor(authenticationLocalStorage: AuthenticationLocalStorage) :
AuthenticationLocalStorage by authenticationLocalStorage {
class SharedPreferencesManager private constructor(
private val combinedSharedPreferencesStorage: CombinedSharedPreferencesStorage
) : AuthenticationLocalStorage by combinedSharedPreferencesStorage, TokenExpirationStorage by combinedSharedPreferencesStorage {

constructor(context: Context, encrypted: Boolean = false, compat: Boolean = false) : this(createAuthenticationLocalStorage(context, encrypted, compat))
constructor(context: Context, type: Type = Type.ONLY_AUTH, variant: Variant = Variant.DEFAULT) : this(createAuthenticationLocalStorage(context, type, variant))

override fun clear() {
combinedSharedPreferencesStorage.clear()
}

enum class Type {
ONLY_AUTH, WITH_TOKEN_EXPIRATION
}

enum class Variant {
COMPAT, ENCRYPTED, DEFAULT
}

companion object {
private fun createAuthenticationLocalStorage(context: Context, encrypted: Boolean, compat: Boolean): AuthenticationLocalStorage =
when {
compat -> AuthenticationSecureSharedPreferencesStorageCompat(context)
encrypted -> AuthenticationSecureSharedPreferencesStorage.create(context)
else -> AuthenticationSharedPreferencesStorage(context)

private fun createAuthenticationLocalStorage(context: Context, type: Type, variant: Variant): CombinedSharedPreferencesStorage =
when (type) {
Type.ONLY_AUTH -> {
val authStorage = when (variant) {
Variant.COMPAT -> AuthenticationSecureSharedPreferencesStorageCompat(context)
Variant.ENCRYPTED -> AuthenticationSecureSharedPreferencesStorage.create(context)
Variant.DEFAULT -> AuthenticationSharedPreferencesStorage(context)
}

CombinedSharedPreferencesStorage(authStorage, NeverExpiredTokenExpirationStorage())
}
Type.WITH_TOKEN_EXPIRATION -> {
when (variant) {
Variant.COMPAT -> CombinedSecureSharedPreferencesStorageCompat.create(context)
Variant.ENCRYPTED -> CombinedSecureSharedPreferencesStorage.create(context)
Variant.DEFAULT -> CombinedSharedPreferencesStorage.create(context)
}
}
}

private class NeverExpiredTokenExpirationStorage : TokenExpirationStorage {

override var accessTokenExpiresAt: Long
get() = Long.MAX_VALUE
set(value) = Unit

override fun clear() = Unit
}
}
}
5 changes: 4 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,16 @@ buildscript {
gsonVersion = "2.8.9"
junitVersion = "4.13.2"
koinVersion = "3.1.5"
kotlinVersion = "1.6.10"
kotlinVersion = "1.6.21"
kotlinPoetVersion = "1.10.2"
moshiVersion = "1.13.0"
okHttpVersion = "4.9.3"
okioVersion = "2.10.0"
retrofitVersion = "2.9.0"
securityVersion = "1.0.0"
robolectricVersion = "4.7"
androidxJunitVersion = "1.1.3"
espressoVersion = "3.2.0"
}
repositories {
google()
Expand Down
2 changes: 2 additions & 0 deletions core/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ dependencies {
testImplementation "junit:junit:$junitVersion"
testImplementation "com.nhaarman.mockitokotlin2:mockito-kotlin:2.1.0"

testImplementation project(":dependenciestest")

// compatibility
testImplementation "com.halcyonmobile.retrofit-error-parsing:retrofit-error-parsing:2.0.1"
// incompatible with error-parsing 2.0.0,
Expand Down
Loading