diff --git a/.idea/gradle.xml b/.idea/gradle.xml
index 7ac24c7..5b05804 100644
--- a/.idea/gradle.xml
+++ b/.idea/gradle.xml
@@ -9,6 +9,7 @@
+
diff --git a/.idea/misc.xml b/.idea/misc.xml
index 75dac50..3963879 100644
--- a/.idea/misc.xml
+++ b/.idea/misc.xml
@@ -24,7 +24,7 @@
-
+
diff --git a/.idea/modules.xml b/.idea/modules.xml
index 5e1adac..f7ba136 100644
--- a/.idea/modules.xml
+++ b/.idea/modules.xml
@@ -4,6 +4,8 @@
+
+
\ No newline at end of file
diff --git a/app/build.gradle b/app/build.gradle
index 09e1b45..667c2c2 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -24,6 +24,7 @@ android {
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
+ implementation project(':mobileauthentication')
implementation"org.jetbrains.kotlin:kotlin-stdlib-jre7:$kotlin_version"
implementation 'com.android.support:appcompat-v7:26.1.0'
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 880ade0..d178ce6 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -2,20 +2,36 @@
+
+
+
+
-
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/java/ca/bc/gov/mobileauthenticationandroidexample/MainActivity.kt b/app/src/main/java/ca/bc/gov/mobileauthenticationandroidexample/MainActivity.kt
index 858f26e..4149bed 100644
--- a/app/src/main/java/ca/bc/gov/mobileauthenticationandroidexample/MainActivity.kt
+++ b/app/src/main/java/ca/bc/gov/mobileauthenticationandroidexample/MainActivity.kt
@@ -1,12 +1,108 @@
package ca.bc.gov.mobileauthenticationandroidexample
+import android.content.Intent
import android.support.v7.app.AppCompatActivity
import android.os.Bundle
+import android.util.Log
+import ca.bc.gov.mobileauthentication.MobileAuthenticationClient
+import ca.bc.gov.mobileauthentication.common.exceptions.NoRefreshTokenException
+import ca.bc.gov.mobileauthentication.common.exceptions.RefreshExpiredException
+import ca.bc.gov.mobileauthentication.common.exceptions.TokenNotFoundException
+import ca.bc.gov.mobileauthentication.data.models.Token
class MainActivity : AppCompatActivity() {
+ private var client: MobileAuthenticationClient? = null
+
+ private val tag = "MOBILE_AUTH"
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
+
+ val authEndpoint = "https://dev-sso.pathfinder.gov.bc.ca/auth/realms/mobile/protocol/openid-connect/auth"
+ val baseUrl = "https://dev-sso.pathfinder.gov.bc.ca/"
+ val clientId = "secure-image"
+ val realmName = "mobile"
+ val redirectUri = "bcgov://android"
+
+ client = MobileAuthenticationClient(this, baseUrl, realmName,
+ authEndpoint, redirectUri, clientId)
+
+ client?.authenticate()
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ client?.clear()
+ }
+
+ override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
+ super.onActivityResult(requestCode, resultCode, data)
+ client?.handleAuthResult(requestCode, resultCode, data, object: MobileAuthenticationClient.TokenCallback {
+ override fun onError(throwable: Throwable) {
+ Log.e(tag, throwable.message)
+ }
+ override fun onSuccess(token: Token) {
+ Log.d(tag, "Authenticate Success")
+ getToken()
+ }
+ })
+ }
+
+ private fun getToken() {
+ client?.getToken(object: MobileAuthenticationClient.TokenCallback {
+ override fun onError(throwable: Throwable) {
+ when (throwable) {
+ is RefreshExpiredException -> {
+ Log.e(tag, "Refresh token is expired. Please re-authenticate.")
+ }
+ is NoRefreshTokenException -> {
+ Log.e(tag, "No Refresh token associated with Token")
+ }
+ is TokenNotFoundException -> {
+ Log.e(tag, "No Token was found")
+ }
+ }
+ }
+
+ override fun onSuccess(token: Token) {
+ Log.d(tag, "Get Success")
+ refreshToken()
+ }
+ })
+ }
+
+ private fun refreshToken() {
+ client?.refreshToken(object: MobileAuthenticationClient.TokenCallback {
+ override fun onError(throwable: Throwable) {
+ Log.e(tag, throwable.message)
+ when (throwable) {
+ is RefreshExpiredException -> {
+ Log.e(tag, "Refresh token is expired. Please re-authenticate.")
+ }
+ is NoRefreshTokenException -> {
+ Log.e(tag, "No Refresh token associated with Token")
+ }
+ }
+ }
+
+ override fun onSuccess(token: Token) {
+ Log.d(tag, "Refresh Success")
+ deleteToken()
+ }
+ })
+ }
+
+ private fun deleteToken() {
+ client?.deleteToken(object: MobileAuthenticationClient.DeleteCallback {
+ override fun onError(throwable: Throwable) {
+ Log.e(tag, throwable.message)
+ }
+
+ override fun onSuccess() {
+ Log.d(tag, "Delete Success")
+ }
+ })
}
}
diff --git a/mobileauthentication/.gitignore b/mobileauthentication/.gitignore
new file mode 100644
index 0000000..796b96d
--- /dev/null
+++ b/mobileauthentication/.gitignore
@@ -0,0 +1 @@
+/build
diff --git a/mobileauthentication/build.gradle b/mobileauthentication/build.gradle
new file mode 100644
index 0000000..b95f3e8
--- /dev/null
+++ b/mobileauthentication/build.gradle
@@ -0,0 +1,60 @@
+apply plugin: 'com.android.library'
+
+apply plugin: 'kotlin-android'
+
+apply plugin: 'kotlin-android-extensions'
+
+android {
+
+ compileSdkVersion 26
+
+ defaultConfig {
+ minSdkVersion 23
+ targetSdkVersion 26
+ compileSdkVersion 26
+ buildToolsVersion '26.0.2'
+ versionCode 1
+ versionName "1.0"
+
+ testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
+
+ }
+
+ buildTypes {
+ release {
+ postprocessing {
+ removeUnusedCode false
+ removeUnusedResources false
+ obfuscate false
+ optimizeCode false
+ proguardFile 'proguard-rules.pro'
+ }
+ }
+ }
+
+}
+
+dependencies {
+ implementation fileTree(dir: 'libs', include: ['*.jar'])
+
+ implementation "com.android.support:appcompat-v7:26.1.0"
+ implementation "com.android.support:customtabs:26.1.0"
+ implementation "com.android.support.constraint:constraint-layout:1.0.2"
+ implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.2.21"
+
+ implementation "io.reactivex.rxjava2:rxandroid:2.0.1"
+ implementation "io.reactivex.rxjava2:rxjava:2.1.7"
+ implementation "io.reactivex.rxjava2:rxkotlin:2.2.0"
+
+ implementation "com.squareup.retrofit2:retrofit:2.3.0"
+ implementation "com.squareup.retrofit2:converter-gson:2.3.0"
+ implementation "com.jakewharton.retrofit:retrofit2-rxjava2-adapter:1.0.0"
+ implementation "com.squareup.okhttp3:logging-interceptor:3.9.1"
+
+ testImplementation "junit:junit:4.12"
+ testImplementation "com.nhaarman:mockito-kotlin:1.5.0"
+}
+
+repositories {
+ mavenCentral()
+}
diff --git a/mobileauthentication/proguard-rules.pro b/mobileauthentication/proguard-rules.pro
new file mode 100644
index 0000000..f1b4245
--- /dev/null
+++ b/mobileauthentication/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
diff --git a/mobileauthentication/src/main/AndroidManifest.xml b/mobileauthentication/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..02b8573
--- /dev/null
+++ b/mobileauthentication/src/main/AndroidManifest.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
diff --git a/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/MobileAuthenticationClient.kt b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/MobileAuthenticationClient.kt
new file mode 100644
index 0000000..f2490dd
--- /dev/null
+++ b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/MobileAuthenticationClient.kt
@@ -0,0 +1,181 @@
+package ca.bc.gov.mobileauthentication
+
+import android.app.Activity
+import android.content.Context
+import android.content.Intent
+import android.preference.PreferenceManager
+import ca.bc.gov.mobileauthentication.common.Constants
+import ca.bc.gov.mobileauthentication.common.exceptions.TokenNotFoundException
+import ca.bc.gov.mobileauthentication.common.utils.UrlUtils
+import ca.bc.gov.mobileauthentication.data.AuthApi
+import ca.bc.gov.mobileauthentication.data.models.Token
+import ca.bc.gov.mobileauthentication.data.repos.token.TokenRepo
+import ca.bc.gov.mobileauthentication.di.Injection
+import ca.bc.gov.mobileauthentication.di.InjectionUtils
+import ca.bc.gov.mobileauthentication.screens.redirect.RedirectActivity
+import com.google.gson.Gson
+import io.reactivex.Observable
+import io.reactivex.android.schedulers.AndroidSchedulers
+import io.reactivex.disposables.CompositeDisposable
+import io.reactivex.rxkotlin.addTo
+import io.reactivex.rxkotlin.subscribeBy
+import io.reactivex.schedulers.Schedulers
+
+/**
+ * Created by Aidan Laing on 2018-01-23.
+ *
+ */
+class MobileAuthenticationClient(
+ private val context: Context,
+ override val baseUrl: String,
+ override val realmName: String,
+ override val authEndpoint: String,
+ override val redirectUri: String,
+ override val clientId: String) : MobileAuthenticationContract {
+
+ private val disposables = CompositeDisposable()
+
+ private val gson: Gson = Injection.provideGson()
+ private val grantType: String = Constants.GRANT_TYPE_AUTH_CODE
+ private val authApi: AuthApi = InjectionUtils.getAuthApi(UrlUtils.cleanBaseUrl(baseUrl))
+ private val sharedPrefs = PreferenceManager.getDefaultSharedPreferences(context.applicationContext)
+ private val tokenRepo: TokenRepo = InjectionUtils.getTokenRepo(
+ authApi, realmName, grantType, redirectUri, clientId, sharedPrefs)
+
+ private var passedRequestCode: Int = DEFAULT_REQUEST_CODE
+
+ /**
+ * Launches intent to activity which will handle OAuth2 Authorization Code Flow
+ */
+ override fun authenticate(requestCode: Int) {
+ this.passedRequestCode = requestCode
+ Intent(context, RedirectActivity::class.java)
+ .putExtra(RedirectActivity.BASE_URL, baseUrl)
+ .putExtra(RedirectActivity.REALM_NAME, realmName)
+ .putExtra(RedirectActivity.AUTH_ENDPOINT, authEndpoint)
+ .putExtra(RedirectActivity.REDIRECT_URI, redirectUri)
+ .putExtra(RedirectActivity.CLIENT_ID, clientId)
+ .run { (context as Activity).startActivityForResult(this, requestCode) }
+ }
+
+ /**
+ * Handles on activity result and determines if the authentication was successful
+ * or an error occurred.
+ */
+ override fun handleAuthResult(
+ requestCode: Int, resultCode: Int, data: Intent?,
+ tokenCallback: TokenCallback) {
+
+ if (resultCode == Activity.RESULT_OK && requestCode == passedRequestCode && data != null) {
+ val success = data.getBooleanExtra(MobileAuthenticationClient.SUCCESS, false)
+ if (success) {
+ val tokenJson = data.getStringExtra(MobileAuthenticationClient.TOKEN_JSON)
+ val token: Token = gson.fromJson(tokenJson, Token::class.java)
+ tokenCallback.onSuccess(token)
+ }
+ else {
+ val errorMessage = data.getStringExtra(MobileAuthenticationClient.ERROR_MESSAGE)
+ tokenCallback.onError(Throwable(errorMessage))
+ }
+ }
+ }
+
+ /**
+ * Gets Token from local storage.
+ * Token will be automatically refreshed if refresh token is not expired.
+ * If refresh token is expired a @see ca.bc.gov.mobileauthentication.common.exceptions.RefreshExpiredException will be thrown
+ * If refresh token does not exist then @see ca.bc.gov.mobileauthentication.common.exceptions.NoRefreshTokenException will be thrown
+ * If a token does not exist a @see ca.bc.gov.mobileauthentication.common.exceptions.TokenNotFoundException will be thrown
+ */
+ override fun getToken(tokenCallback: TokenCallback) {
+ tokenRepo.getToken()
+ .firstElement()
+ .subscribeOn(Schedulers.io())
+ .observeOn(AndroidSchedulers.mainThread()).subscribeBy(
+ onError = {
+ tokenCallback.onError(it)
+ },
+ onSuccess = { token ->
+ tokenCallback.onSuccess(token)
+ },
+ onComplete = {
+ tokenCallback.onError(TokenNotFoundException())
+ }
+ ).addTo(disposables)
+ }
+
+ /**
+ * Gets Token from local storage as a RxJava2 Observable
+ */
+ override fun getTokenAsObservable(): Observable = tokenRepo.getToken()
+
+ /**
+ * Refreshes token
+ * If refresh token is expired a @see ca.bc.gov.mobileauthentication.common.exceptions.RefreshExpiredException will be thrown
+ * If refresh token does not exist then @see ca.bc.gov.mobileauthentication.common.exceptions.NoRefreshTokenException will be thrown
+ */
+ override fun refreshToken(tokenCallback: TokenCallback) {
+ tokenRepo.getToken()
+ .flatMap { token -> tokenRepo.refreshToken(token) }
+ .firstOrError()
+ .subscribeOn(Schedulers.io())
+ .observeOn(AndroidSchedulers.mainThread()).subscribeBy(
+ onError = {
+ tokenCallback.onError(it)
+ },
+ onSuccess = { token ->
+ tokenCallback.onSuccess(token)
+ }
+ ).addTo(disposables)
+ }
+
+ /**
+ * Refreshes Token that is stored in local storage as a RxJava2 Observable
+ */
+ override fun refreshTokenAsObservable(): Observable = tokenRepo.getToken()
+ .flatMap { token -> tokenRepo.refreshToken(token) }
+
+ /**
+ * Deletes token from local storage
+ */
+ override fun deleteToken(deleteCallback: DeleteCallback) {
+ tokenRepo.deleteToken()
+ .ignoreElements().subscribeBy(
+ onError = {
+ deleteCallback.onError(it)
+ },
+ onComplete = {
+ deleteCallback.onSuccess()
+ }
+ ).addTo(disposables)
+ }
+
+ /**
+ * Deletes token from local storage as a RxJava2 Observable
+ */
+ override fun deleteTokenAsObservable(): Observable = tokenRepo.deleteToken()
+
+ /**
+ * Clears all current callbacks
+ */
+ override fun clear() {
+ disposables.clear()
+ }
+
+ interface TokenCallback {
+ fun onError(throwable: Throwable)
+ fun onSuccess(token: Token)
+ }
+
+ interface DeleteCallback {
+ fun onError(throwable: Throwable)
+ fun onSuccess()
+ }
+
+ companion object {
+ const val DEFAULT_REQUEST_CODE = 1012
+ const val SUCCESS = "SUCCESS"
+ const val ERROR_MESSAGE = "ERROR_MESSAGE"
+ const val TOKEN_JSON = "TOKEN_JSON"
+ }
+}
\ No newline at end of file
diff --git a/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/MobileAuthenticationContract.kt b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/MobileAuthenticationContract.kt
new file mode 100644
index 0000000..3db8a98
--- /dev/null
+++ b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/MobileAuthenticationContract.kt
@@ -0,0 +1,35 @@
+package ca.bc.gov.mobileauthentication
+
+import android.content.Intent
+import ca.bc.gov.mobileauthentication.data.models.Token
+import io.reactivex.Observable
+
+/**
+ * Created by Aidan Laing on 2018-01-23.
+ *
+ */
+interface MobileAuthenticationContract {
+
+ val baseUrl: String
+ val realmName: String
+ val authEndpoint: String
+ val redirectUri: String
+ val clientId: String
+
+ fun authenticate(requestCode: Int = MobileAuthenticationClient.DEFAULT_REQUEST_CODE)
+
+ fun handleAuthResult(requestCode: Int, resultCode: Int, data: Intent?,
+ tokenCallback: MobileAuthenticationClient.TokenCallback)
+
+ fun getToken(tokenCallback: MobileAuthenticationClient.TokenCallback)
+ fun getTokenAsObservable(): Observable
+
+ fun refreshToken(tokenCallback: MobileAuthenticationClient.TokenCallback)
+ fun refreshTokenAsObservable(): Observable
+
+ fun deleteToken(deleteCallback: MobileAuthenticationClient.DeleteCallback)
+ fun deleteTokenAsObservable(): Observable
+
+ fun clear()
+
+}
\ No newline at end of file
diff --git a/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/common/BasePresenter.kt b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/common/BasePresenter.kt
new file mode 100644
index 0000000..5fdfe97
--- /dev/null
+++ b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/common/BasePresenter.kt
@@ -0,0 +1,13 @@
+package ca.bc.gov.mobileauthentication.common
+
+/**
+ * Created by Aidan Laing on 2017-12-12.
+ *
+ */
+interface BasePresenter {
+
+ fun subscribe()
+
+ fun dispose()
+
+}
\ No newline at end of file
diff --git a/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/common/BaseView.kt b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/common/BaseView.kt
new file mode 100644
index 0000000..63215bb
--- /dev/null
+++ b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/common/BaseView.kt
@@ -0,0 +1,11 @@
+package ca.bc.gov.mobileauthentication.common
+
+/**
+ * Created by Aidan Laing on 2017-12-12.
+ *
+ */
+interface BaseView {
+
+ var presenter: T?
+
+}
\ No newline at end of file
diff --git a/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/common/Constants.kt b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/common/Constants.kt
new file mode 100644
index 0000000..b2b32c5
--- /dev/null
+++ b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/common/Constants.kt
@@ -0,0 +1,13 @@
+package ca.bc.gov.mobileauthentication.common
+
+/**
+ * Created by Aidan Laing on 2018-01-18.
+ *
+ */
+object Constants {
+ const val READ_TIME_OUT = 20L
+ const val CONNECT_TIME_OUT = 20L
+
+ const val GRANT_TYPE_AUTH_CODE = "authorization_code"
+ const val RESPONSE_TYPE_CODE = "code"
+}
\ No newline at end of file
diff --git a/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/common/exceptions/InvalidOperationException.kt b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/common/exceptions/InvalidOperationException.kt
new file mode 100644
index 0000000..3790458
--- /dev/null
+++ b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/common/exceptions/InvalidOperationException.kt
@@ -0,0 +1,11 @@
+package ca.bc.gov.mobileauthentication.common.exceptions
+
+/**
+ * Created by Aidan Laing on 2018-01-05.
+ * Thrown when an incorrect operation is called on a data source.
+ */
+class InvalidOperationException: Throwable() {
+
+ override val message: String? get() = "Invalid operation"
+
+}
\ No newline at end of file
diff --git a/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/common/exceptions/NoCodeException.kt b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/common/exceptions/NoCodeException.kt
new file mode 100644
index 0000000..873d5f1
--- /dev/null
+++ b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/common/exceptions/NoCodeException.kt
@@ -0,0 +1,10 @@
+package ca.bc.gov.mobileauthentication.common.exceptions
+
+/**
+ * Created by Aidan Laing on 2018-01-31.
+ * Thrown when there is no code to exchange for a token remotely.
+ */
+class NoCodeException : Throwable() {
+ override val message: String?
+ get() = "Code is required for getting token"
+}
\ No newline at end of file
diff --git a/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/common/exceptions/NoRefreshTokenException.kt b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/common/exceptions/NoRefreshTokenException.kt
new file mode 100644
index 0000000..b01d69d
--- /dev/null
+++ b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/common/exceptions/NoRefreshTokenException.kt
@@ -0,0 +1,10 @@
+package ca.bc.gov.mobileauthentication.common.exceptions
+
+/**
+ * Created by Aidan Laing on 2018-01-31.
+ * Thrown when token refresh is called and the refresh token does not exist.
+ */
+class NoRefreshTokenException : Throwable() {
+ override val message: String?
+ get() = "No refresh token"
+}
\ No newline at end of file
diff --git a/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/common/exceptions/RefreshExpiredException.kt b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/common/exceptions/RefreshExpiredException.kt
new file mode 100644
index 0000000..9a3fc22
--- /dev/null
+++ b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/common/exceptions/RefreshExpiredException.kt
@@ -0,0 +1,10 @@
+package ca.bc.gov.mobileauthentication.common.exceptions
+
+/**
+ * Created by Aidan Laing on 2018-01-31.
+ * Thrown when token refresh is called and the refresh is expired.
+ */
+class RefreshExpiredException : Throwable() {
+ override val message: String?
+ get() = "Refresh token has expired. Please get new token using authenticate."
+}
\ No newline at end of file
diff --git a/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/common/exceptions/TokenNotFoundException.kt b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/common/exceptions/TokenNotFoundException.kt
new file mode 100644
index 0000000..3f002e0
--- /dev/null
+++ b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/common/exceptions/TokenNotFoundException.kt
@@ -0,0 +1,10 @@
+package ca.bc.gov.mobileauthentication.common.exceptions
+
+/**
+ * Created by Aidan Laing on 2018-01-31.
+ * Thrown when local database is queried and no Token exists.
+ */
+class TokenNotFoundException : Throwable() {
+ override val message: String?
+ get() = "No token found. Please call authenticate before trying to retrieve a token locally."
+}
\ No newline at end of file
diff --git a/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/common/utils/UrlUtils.kt b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/common/utils/UrlUtils.kt
new file mode 100644
index 0000000..747feb9
--- /dev/null
+++ b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/common/utils/UrlUtils.kt
@@ -0,0 +1,34 @@
+package ca.bc.gov.mobileauthentication.common.utils
+
+/**
+ * Created by Aidan Laing on 2018-01-23.
+ *
+ */
+object UrlUtils {
+
+ /**
+ * Checks to see if base url ends with a /
+ * If the url ends with a / then return the passed url
+ * If the url DOES NOT end with a / then return the passed url concatenated with /
+ */
+ fun cleanBaseUrl(baseUrl: String): String {
+ return if (!baseUrl.endsWith("/")) {
+ var cleanedBaseUrl = baseUrl
+ cleanedBaseUrl += "/"
+ cleanedBaseUrl
+ } else {
+ baseUrl
+ }
+ }
+
+ /**
+ * Extracts code query param form url by taking a substring between
+ * code= and the first & or the end of the String
+ */
+ fun extractCode(codeUrl: String): String {
+ return if (codeUrl.contains("code=".toRegex())) {
+ codeUrl.substringAfter("code=").substringBefore("&")
+ } else ""
+ }
+
+}
\ No newline at end of file
diff --git a/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/data/AuthApi.kt b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/data/AuthApi.kt
new file mode 100644
index 0000000..b1073de
--- /dev/null
+++ b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/data/AuthApi.kt
@@ -0,0 +1,42 @@
+package ca.bc.gov.mobileauthentication.data
+
+import ca.bc.gov.mobileauthentication.data.models.Token
+import io.reactivex.Observable
+import retrofit2.http.Field
+import retrofit2.http.FormUrlEncoded
+import retrofit2.http.POST
+import retrofit2.http.Path
+
+/**
+ * Created by Aidan Laing on 2018-01-18.
+ *
+ */
+interface AuthApi {
+
+ /**
+ * OAuth2 get Token call
+ */
+ @POST("/auth/realms/{realm_name}/protocol/openid-connect/token")
+ @FormUrlEncoded
+ fun getToken(
+ @Path("realm_name") realmName: String,
+ @Field("grant_type") grantType: String,
+ @Field("redirect_uri") redirectUri: String,
+ @Field("client_id") client_id: String,
+ @Field("code") code: String
+ ): Observable
+
+ /**
+ * OAuth2 refresh Token call
+ */
+ @POST("/auth/realms/{realm_name}/protocol/openid-connect/token")
+ @FormUrlEncoded
+ fun refreshToken(
+ @Path("realm_name") realmName: String,
+ @Field("redirect_uri") redirectUri: String,
+ @Field("client_id") client_id: String,
+ @Field("refresh_token") refreshToken: String,
+ @Field("grant_type") grantType: String = "refresh_token"
+ ): Observable
+
+}
\ No newline at end of file
diff --git a/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/data/models/Token.kt b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/data/models/Token.kt
new file mode 100644
index 0000000..ed3bb7f
--- /dev/null
+++ b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/data/models/Token.kt
@@ -0,0 +1,26 @@
+package ca.bc.gov.mobileauthentication.data.models
+
+import com.google.gson.annotations.SerializedName
+
+/**
+ * Created by Aidan Laing on 2018-01-18.
+ *
+ */
+data class Token(
+ @SerializedName("access_token") val accessToken: String?,
+ @SerializedName("expires_in") val expiresIn: Long?,
+ @SerializedName("refresh_expires_in") val refreshExpiresIn: Long?,
+ @SerializedName("refresh_token") val refreshToken: String?,
+ @SerializedName("token_type") val bearer: String?,
+ @SerializedName("id_token") val idToken: String?,
+ @SerializedName("not-before-policy") val notBeforePolicy: Long?,
+ @SerializedName("session_state") val sessionState: String?,
+ val expiresAt: Long = System.currentTimeMillis() + (expiresIn ?: 0 * 1000),
+ val refreshExpiresAt: Long = System.currentTimeMillis() + (refreshExpiresIn ?: 0 * 1000)
+) {
+
+ fun isExpired(currentTime: Long = System.currentTimeMillis()): Boolean = expiresAt > currentTime
+
+ fun isRefreshExpired(currentTime: Long = System.currentTimeMillis()): Boolean = refreshExpiresAt > currentTime
+
+}
\ No newline at end of file
diff --git a/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/data/repos/token/SecureSharedPrefs.kt b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/data/repos/token/SecureSharedPrefs.kt
new file mode 100644
index 0000000..dc153a9
--- /dev/null
+++ b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/data/repos/token/SecureSharedPrefs.kt
@@ -0,0 +1,147 @@
+package ca.bc.gov.mobileauthentication.data.repos.token
+
+import android.content.SharedPreferences
+import android.security.keystore.KeyGenParameterSpec
+import android.security.keystore.KeyProperties
+import android.util.Base64
+import java.nio.charset.Charset
+import java.security.KeyStore
+import javax.crypto.Cipher
+import javax.crypto.KeyGenerator
+import javax.crypto.SecretKey
+import javax.crypto.spec.GCMParameterSpec
+
+/**
+ * Created by Aidan Laing on 2018-01-23.
+ *
+ */
+class SecureSharedPrefs(
+ private val keyStore: KeyStore,
+ private val sharedPrefs: SharedPreferences
+) {
+
+ init {
+ keyStore.load(null)
+ }
+
+ /**
+ * Gets String from shared prefs, decrypts it, and returns the result
+ */
+ fun getString(key: String): String {
+ val aesSecretKey = getAESSecretKey(key, 256)
+ return getDecryptedString(key, aesSecretKey)
+ }
+
+ /**
+ * Saves an encrypted version of the passed String to shared prefs
+ */
+ fun saveString(key: String, data: String) {
+ val aesSecretKey = getAESSecretKey(key, 256)
+ encryptAndSaveString(key, data, aesSecretKey)
+ }
+
+ /**
+ * Deletes String associated with key in shared prefs
+ */
+ fun deleteString(key: String) {
+ sharedPrefs.edit().remove(key).apply()
+ }
+
+ /**
+ * Gets AES Secret key form keystore
+ * Generates a new one if it does not exist
+ */
+ private fun getAESSecretKey(
+ alias: String,
+ keySize: Int
+ ): SecretKey {
+
+ // Generating AES key if alias is not found
+ if (!keyStore.containsAlias(alias)) {
+ generateAESSecretKey(alias, keySize)
+ }
+
+ // Getting secret key from keystore and returning
+ return keyStore.getKey(alias, null) as SecretKey
+ }
+
+ /**
+ * Builds new AES Secret key
+ */
+ private fun generateAESSecretKey(alias: String, keySize: Int) {
+ val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, keyStore.provider)
+
+ keyGenerator.init(KeyGenParameterSpec.Builder(
+ alias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
+ .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
+ .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
+ .setKeySize(keySize)
+ .build())
+
+ keyGenerator.generateKey()
+ }
+
+ /**
+ * Encrypts passed string and saves in shared prefs
+ */
+ private fun encryptAndSaveString(
+ key: String,
+ data: String,
+ aesSecretKey: SecretKey) {
+
+ // Encryption setup
+ val encryptionCipher = Cipher.getInstance(AES_TRANSFORMATION)
+ encryptionCipher.init(Cipher.ENCRYPT_MODE, aesSecretKey)
+
+ val dataBytes = data.toByteArray(Charset.defaultCharset())
+ val encryptedBytes = encryptionCipher.doFinal(dataBytes)
+
+ // Saving base 64 encoded encrypted bytes
+ val base64EncryptedBytesString = Base64.encodeToString(encryptedBytes, Base64.DEFAULT)
+ sharedPrefs.edit().putString(key, base64EncryptedBytesString).apply()
+
+ // Saving base 64 encoded initialization vector
+ val base64IvString = Base64.encodeToString(encryptionCipher.iv, Base64.DEFAULT)
+ sharedPrefs.edit().putString(getIvKey(key), base64IvString).apply()
+ }
+
+ /**
+ * Gets String associated with key in shared prefs and decrypts it
+ */
+ private fun getDecryptedString(
+ key: String,
+ aesSecretKey: SecretKey
+ ): String {
+
+ if (!sharedPrefs.contains(key) || !sharedPrefs.contains(getIvKey(key))) return ""
+
+ // Decoding saved encrypted bytes
+ val base64EncryptedBytesString = sharedPrefs.getString(key, "")
+ val encryptedBytes = Base64.decode(base64EncryptedBytesString, Base64.DEFAULT)
+
+ // Decoding saved initialization vector
+ val base64IvString = sharedPrefs.getString(getIvKey(key), "")
+ val savedIv = Base64.decode(base64IvString, Base64.DEFAULT)
+
+ // Decryption setup
+ val decryptionCipher = Cipher.getInstance(AES_TRANSFORMATION)
+ val gcmParameterSpec = GCMParameterSpec(128, savedIv)
+ decryptionCipher.init(Cipher.DECRYPT_MODE, aesSecretKey, gcmParameterSpec)
+
+ // Decrypt encrypted bytes and return
+ val decryptedBytes = decryptionCipher.doFinal(encryptedBytes)
+ return decryptedBytes.toString(Charset.defaultCharset())
+ }
+
+ /**
+ * Gets the iv key for the passed key
+ */
+ private fun getIvKey(key: String): String {
+ return "iv_key_$key"
+ }
+
+ companion object {
+ private val AES_TRANSFORMATION = "AES/GCM/NoPadding"
+ }
+
+}
\ No newline at end of file
diff --git a/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/data/repos/token/TokenDataSource.kt b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/data/repos/token/TokenDataSource.kt
new file mode 100644
index 0000000..09cfa85
--- /dev/null
+++ b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/data/repos/token/TokenDataSource.kt
@@ -0,0 +1,20 @@
+package ca.bc.gov.mobileauthentication.data.repos.token
+
+import ca.bc.gov.mobileauthentication.data.models.Token
+import io.reactivex.Observable
+
+/**
+ * Created by Aidan Laing on 2018-01-23.
+ *
+ */
+interface TokenDataSource {
+
+ fun getToken(code: String? = null): Observable
+
+ fun saveToken(token: Token): Observable
+
+ fun refreshToken(token: Token): Observable
+
+ fun deleteToken(): Observable
+
+}
\ No newline at end of file
diff --git a/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/data/repos/token/TokenLocalDataSource.kt b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/data/repos/token/TokenLocalDataSource.kt
new file mode 100644
index 0000000..355bfc5
--- /dev/null
+++ b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/data/repos/token/TokenLocalDataSource.kt
@@ -0,0 +1,78 @@
+package ca.bc.gov.mobileauthentication.data.repos.token
+
+import ca.bc.gov.mobileauthentication.common.exceptions.InvalidOperationException
+import ca.bc.gov.mobileauthentication.data.models.Token
+import com.google.gson.Gson
+import io.reactivex.Observable
+
+/**
+ * Created by Aidan Laing on 2018-01-23.
+ *
+ */
+class TokenLocalDataSource
+private constructor(
+ private val gson: Gson,
+ private val secureSharedPrefs: SecureSharedPrefs
+) : TokenDataSource {
+
+ companion object {
+
+ private const val TOKEN_KEY = "TOKEN_KEY"
+
+ @Volatile
+ private var INSTANCE: TokenLocalDataSource? = null
+
+ fun getInstance(gson: Gson, secureSharedPrefs: SecureSharedPrefs): TokenLocalDataSource =
+ INSTANCE ?: synchronized(this) {
+ INSTANCE ?: TokenLocalDataSource(gson, secureSharedPrefs)
+ .also { INSTANCE = it }
+ }
+ }
+
+ /**
+ * Gets token from local db and returns
+ */
+ override fun getToken(code: String?): Observable {
+ return Observable.create { emitter ->
+ val tokenJson = secureSharedPrefs.getString(TOKEN_KEY)
+ if (tokenJson.isNotBlank()) {
+ val token: Token = gson.fromJson(tokenJson, Token::class.java)
+ emitter.onNext(token)
+ }
+ emitter.onComplete()
+ }
+ }
+
+ /**
+ * Saves token to local db and returns saved version
+ */
+ override fun saveToken(token: Token): Observable {
+ return Observable.create { emitter ->
+ val tokenJson = gson.toJson(token)
+ secureSharedPrefs.saveString(TOKEN_KEY, tokenJson)
+
+ val savedTokenJson = secureSharedPrefs.getString(TOKEN_KEY)
+ val savedToken: Token = gson.fromJson(savedTokenJson, Token::class.java)
+ emitter.onNext(savedToken)
+ emitter.onComplete()
+ }
+ }
+
+ /**
+ * Invalid operation for local data source
+ */
+ override fun refreshToken(token: Token): Observable {
+ return Observable.error(InvalidOperationException())
+ }
+
+ /**
+ * Deletes token form local db
+ */
+ override fun deleteToken(): Observable {
+ return Observable.create { emitter ->
+ secureSharedPrefs.deleteString(TOKEN_KEY)
+ emitter.onNext(true)
+ emitter.onComplete()
+ }
+ }
+}
\ No newline at end of file
diff --git a/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/data/repos/token/TokenRemoteDataSource.kt b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/data/repos/token/TokenRemoteDataSource.kt
new file mode 100644
index 0000000..3c62e64
--- /dev/null
+++ b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/data/repos/token/TokenRemoteDataSource.kt
@@ -0,0 +1,69 @@
+package ca.bc.gov.mobileauthentication.data.repos.token
+
+import ca.bc.gov.mobileauthentication.common.exceptions.InvalidOperationException
+import ca.bc.gov.mobileauthentication.common.exceptions.NoCodeException
+import ca.bc.gov.mobileauthentication.common.exceptions.NoRefreshTokenException
+import ca.bc.gov.mobileauthentication.data.AuthApi
+import ca.bc.gov.mobileauthentication.data.models.Token
+import io.reactivex.Observable
+
+/**
+ * Created by Aidan Laing on 2018-01-23.
+ *
+ */
+class TokenRemoteDataSource
+private constructor(
+ private val authApi: AuthApi,
+ private val realmName: String,
+ private val grantType: String,
+ private val redirectUri: String,
+ private val clientId: String
+) : TokenDataSource {
+
+ companion object {
+
+ @Volatile
+ private var INSTANCE: TokenRemoteDataSource? = null
+
+ fun getInstance(
+ authApi: AuthApi,
+ realmName: String,
+ grantType: String,
+ redirectUri: String,
+ clientId: String): TokenRemoteDataSource = INSTANCE ?: synchronized(this) {
+ INSTANCE ?: TokenRemoteDataSource(
+ authApi, realmName, grantType, redirectUri, clientId).also { INSTANCE = it }
+ }
+ }
+
+ /**
+ * Exchanges code for token using authentication api
+ * Returns error if code is null
+ */
+ override fun getToken(code: String?): Observable {
+ if (code == null) return Observable.error(NoCodeException())
+ return authApi.getToken(realmName, grantType, redirectUri, clientId, code)
+ }
+
+ /**
+ * Invalid operation for remote data source
+ */
+ override fun saveToken(token: Token): Observable {
+ return Observable.error(InvalidOperationException())
+ }
+
+ /**
+ * Refreshes token using authentication api
+ * Returns error if there is no refresh token
+ */
+ override fun refreshToken(token: Token): Observable {
+ val refreshToken = token.refreshToken ?: return Observable.error(NoRefreshTokenException())
+ return authApi.refreshToken(realmName, redirectUri, clientId, refreshToken)
+ }
+ /**
+ * Invalid operation for remote data source
+ */
+ override fun deleteToken(): Observable {
+ return Observable.error(InvalidOperationException())
+ }
+}
\ No newline at end of file
diff --git a/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/data/repos/token/TokenRepo.kt b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/data/repos/token/TokenRepo.kt
new file mode 100644
index 0000000..9f6eaa1
--- /dev/null
+++ b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/data/repos/token/TokenRepo.kt
@@ -0,0 +1,81 @@
+package ca.bc.gov.mobileauthentication.data.repos.token
+
+import ca.bc.gov.mobileauthentication.common.exceptions.RefreshExpiredException
+import ca.bc.gov.mobileauthentication.data.models.Token
+import io.reactivex.Observable
+
+/**
+ * Created by Aidan Laing on 2018-01-23.
+ *
+ */
+class TokenRepo
+private constructor(
+ private val remoteDataSource: TokenDataSource,
+ private val localDataSource: TokenDataSource
+) : TokenDataSource {
+
+ companion object {
+
+ @Volatile
+ private var INSTANCE: TokenRepo? = null
+
+ fun getInstance(
+ remoteDataSource: TokenDataSource,
+ localDataSource: TokenDataSource
+ ): TokenRepo = INSTANCE ?: synchronized(this) {
+ INSTANCE ?: TokenRepo(remoteDataSource, localDataSource)
+ .also { INSTANCE = it }
+ }
+
+ fun destroyInstance() {
+ INSTANCE = null
+ }
+ }
+
+ /**
+ * Gets token from remote if code IS NOT null
+ * Gets token from local if code IS null
+ * Returns token if valid
+ * If token from local db refresh is expired then @see ca.bc.gov.mobileauthentication.common.exceptions.RefreshExpiredException will be thrown.
+ * If refresh token is not expired and token is expired then token will be refreshed and returned
+ */
+ override fun getToken(code: String?): Observable {
+ return if (code != null) {
+ remoteDataSource.getToken(code)
+ .flatMap { localDataSource.saveToken(it) }
+ } else {
+ localDataSource.getToken()
+ .flatMap {
+ when {
+ it.isRefreshExpired() -> Observable.error(RefreshExpiredException())
+ it.isExpired() -> refreshToken(it)
+ else -> Observable.just(it)
+ }
+ }
+ }
+ }
+
+ /**
+ * Saves token locally
+ */
+ override fun saveToken(token: Token): Observable {
+ return localDataSource.saveToken(token)
+ }
+
+ /**
+ * Refreshes token and saves to local db
+ * If passed token refresh token is expired then @see ca.bc.gov.mobileauthentication.common.exceptions.RefreshExpiredException will be thrown.
+ */
+ override fun refreshToken(token: Token): Observable {
+ return if (token.isRefreshExpired()) Observable.error(RefreshExpiredException())
+ else remoteDataSource.refreshToken(token)
+ .flatMap { localDataSource.saveToken(it) }
+ }
+
+ /**
+ * Deletes token from local db
+ */
+ override fun deleteToken(): Observable {
+ return localDataSource.deleteToken()
+ }
+}
\ No newline at end of file
diff --git a/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/di/Injection.kt b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/di/Injection.kt
new file mode 100644
index 0000000..54f4091
--- /dev/null
+++ b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/di/Injection.kt
@@ -0,0 +1,108 @@
+package ca.bc.gov.mobileauthentication.di
+
+import android.content.SharedPreferences
+import ca.bc.gov.mobileauthentication.data.AuthApi
+import ca.bc.gov.mobileauthentication.data.repos.token.SecureSharedPrefs
+import ca.bc.gov.mobileauthentication.data.repos.token.TokenLocalDataSource
+import ca.bc.gov.mobileauthentication.data.repos.token.TokenRemoteDataSource
+import ca.bc.gov.mobileauthentication.data.repos.token.TokenRepo
+import com.google.gson.Gson
+import com.google.gson.GsonBuilder
+import com.jakewharton.retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory
+import okhttp3.OkHttpClient
+import okhttp3.logging.HttpLoggingInterceptor
+import retrofit2.CallAdapter
+import retrofit2.Converter
+import retrofit2.Retrofit
+import retrofit2.converter.gson.GsonConverterFactory
+import java.security.KeyStore
+import java.util.concurrent.TimeUnit
+
+/**
+ * Created by Aidan Laing on 2018-01-18.
+ *
+ */
+object Injection {
+
+ // OkHttpClient
+ @JvmStatic
+ fun provideOkHttpClient(
+ readTimeOut: Long,
+ connectTimeOut: Long,
+ interceptor: HttpLoggingInterceptor
+ ): OkHttpClient = OkHttpClient.Builder()
+ .readTimeout(readTimeOut, TimeUnit.SECONDS)
+ .connectTimeout(connectTimeOut, TimeUnit.SECONDS)
+ .addInterceptor(interceptor)
+ .build()
+
+ // Logging interceptor
+ @JvmStatic
+ fun provideHttpLoggingInterceptor(
+ loggingLevel: HttpLoggingInterceptor.Level
+ ): HttpLoggingInterceptor = HttpLoggingInterceptor()
+ .apply { level = loggingLevel }
+
+ // Gson
+ private var cachedGson: Gson? = null
+
+ @JvmStatic
+ fun provideGson(): Gson = cachedGson ?: GsonBuilder()
+ .setLenient()
+ .create()
+ .also { cachedGson = it }
+
+ // Converter Factory
+ @JvmStatic
+ fun provideConverterFactory(gson: Gson): Converter.Factory = GsonConverterFactory.create(gson)
+
+ // Call Adapter Factory
+ private var cachedCallAdapterFactory: CallAdapter.Factory? = null
+
+ @JvmStatic
+ fun provideCallAdapterFactory(): CallAdapter.Factory = cachedCallAdapterFactory
+ ?:
+ RxJava2CallAdapterFactory.create()
+ .also { cachedCallAdapterFactory = it }
+
+ // Retrofit
+ @JvmStatic
+ fun provideRetrofit(
+ apiDomain: String,
+ okHttpClient: OkHttpClient,
+ converterFactory: Converter.Factory,
+ callAdapterFactory: CallAdapter.Factory
+ ): Retrofit = Retrofit.Builder()
+ .baseUrl(apiDomain)
+ .client(okHttpClient)
+ .addConverterFactory(converterFactory)
+ .addCallAdapterFactory(callAdapterFactory)
+ .build()
+
+ // Auth Api
+ @JvmStatic
+ fun provideAuthApi(retrofit: Retrofit): AuthApi = retrofit.create(AuthApi::class.java)
+
+ // Token Repo
+ @JvmStatic
+ fun provideTokenRepo(
+ authApi: AuthApi,
+ realmName: String,
+ grantType: String,
+ redirectUri: String,
+ clientId: String,
+ gson: Gson,
+ secureSharedPrefs: SecureSharedPrefs
+ ): TokenRepo = TokenRepo.getInstance(
+ TokenRemoteDataSource.getInstance(authApi, realmName, grantType, redirectUri, clientId),
+ TokenLocalDataSource.getInstance(gson, secureSharedPrefs))
+
+ // Secure shared prefs
+ @JvmStatic
+ fun provideSecureSharedPrefs(
+ keyStore: KeyStore, sharedPreferences: SharedPreferences): SecureSharedPrefs =
+ SecureSharedPrefs(keyStore, sharedPreferences)
+
+ @JvmStatic
+ fun provideKeyStore(): KeyStore = KeyStore.getInstance("AndroidKeyStore")
+}
\ No newline at end of file
diff --git a/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/di/InjectionUtils.kt b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/di/InjectionUtils.kt
new file mode 100644
index 0000000..3709ee1
--- /dev/null
+++ b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/di/InjectionUtils.kt
@@ -0,0 +1,61 @@
+package ca.bc.gov.mobileauthentication.di
+
+import android.content.SharedPreferences
+import ca.bc.gov.mobileauthentication.data.AuthApi
+import ca.bc.gov.mobileauthentication.common.Constants
+import ca.bc.gov.mobileauthentication.data.repos.token.SecureSharedPrefs
+import ca.bc.gov.mobileauthentication.data.repos.token.TokenLocalDataSource
+import ca.bc.gov.mobileauthentication.data.repos.token.TokenRemoteDataSource
+import ca.bc.gov.mobileauthentication.data.repos.token.TokenRepo
+import com.google.gson.Gson
+import okhttp3.OkHttpClient
+import okhttp3.logging.HttpLoggingInterceptor
+import retrofit2.CallAdapter
+import retrofit2.Converter
+import retrofit2.Retrofit
+import java.security.KeyStore
+
+/**
+ * Created by Aidan Laing on 2018-01-18.
+ *
+ */
+object InjectionUtils {
+
+ /**
+ * Gets Auth Api with standard params
+ */
+ fun getAuthApi(
+ apiDomain: String,
+ gson: Gson = Injection.provideGson(),
+ converterFactory: Converter.Factory = Injection.provideConverterFactory(gson),
+ callAdapterFactory : CallAdapter.Factory = Injection.provideCallAdapterFactory(),
+ readTimeOut: Long = Constants.READ_TIME_OUT,
+ connectTimeOut: Long = Constants.CONNECT_TIME_OUT,
+ loggingLevel: HttpLoggingInterceptor.Level = HttpLoggingInterceptor.Level.BODY,
+ httpLoggingInterceptor: HttpLoggingInterceptor = Injection.provideHttpLoggingInterceptor(
+ loggingLevel),
+ okHttpClient: OkHttpClient = Injection.provideOkHttpClient(
+ readTimeOut, connectTimeOut, httpLoggingInterceptor),
+ retrofit: Retrofit = Injection.provideRetrofit(
+ apiDomain, okHttpClient, converterFactory, callAdapterFactory)
+ ): AuthApi = Injection.provideAuthApi(retrofit)
+
+ /**
+ * Gets Token Repo with standard params
+ */
+ fun getTokenRepo(
+ authApi: AuthApi,
+ realmName: String,
+ grantType: String,
+ redirectUri: String,
+ clientId: String,
+ sharedPreferences: SharedPreferences,
+ gson: Gson = Injection.provideGson(),
+ keyStore: KeyStore = Injection.provideKeyStore(),
+ secureSharedPrefs: SecureSharedPrefs = Injection.provideSecureSharedPrefs(keyStore, sharedPreferences)
+ ): TokenRepo = TokenRepo.getInstance(
+ TokenRemoteDataSource.getInstance(authApi, realmName, grantType, redirectUri, clientId),
+ TokenLocalDataSource.getInstance(gson, secureSharedPrefs)
+ )
+
+}
\ No newline at end of file
diff --git a/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/screens/redirect/RedirectActivity.kt b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/screens/redirect/RedirectActivity.kt
new file mode 100644
index 0000000..23fc0ac
--- /dev/null
+++ b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/screens/redirect/RedirectActivity.kt
@@ -0,0 +1,163 @@
+package ca.bc.gov.mobileauthentication.screens.redirect
+
+import android.app.Activity
+import android.content.Intent
+import android.net.Uri
+import android.os.Bundle
+import android.preference.PreferenceManager
+import android.widget.Toast
+import ca.bc.gov.mobileauthentication.R
+import ca.bc.gov.mobileauthentication.di.InjectionUtils
+import android.support.customtabs.CustomTabsIntent
+import android.support.v4.content.ContextCompat
+import android.support.v7.app.AppCompatActivity
+import android.view.View
+import ca.bc.gov.mobileauthentication.MobileAuthenticationClient
+import ca.bc.gov.mobileauthentication.common.Constants
+import ca.bc.gov.mobileauthentication.common.utils.UrlUtils
+import ca.bc.gov.mobileauthentication.di.Injection
+import kotlinx.android.synthetic.main.activity_login.*
+
+class RedirectActivity : AppCompatActivity(), RedirectContract.View {
+
+ override var presenter: RedirectContract.Presenter? = null
+
+ override var loading: Boolean = false
+
+ companion object {
+ const val BASE_URL = "BASE_URL"
+ const val REALM_NAME = "REALM_NAME"
+ const val AUTH_ENDPOINT = "AUTH_ENDPOINT"
+ const val REDIRECT_URI = "REDIRECT_URI"
+ const val CLIENT_ID = "CLIENT_ID"
+ }
+
+ // Life cycle
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(R.layout.activity_login)
+
+ val baseUrl: String? = intent.getStringExtra(BASE_URL)
+ val realmName: String? = intent.getStringExtra(REALM_NAME)
+ val authEndpoint: String? = intent.getStringExtra(AUTH_ENDPOINT)
+ val redirectUri: String? = intent.getStringExtra(REDIRECT_URI)
+ val clientId: String? = intent.getStringExtra(CLIENT_ID)
+
+ // Checking for required params
+ if (baseUrl == null) {
+ showToastAndFinish(getString(R.string.error_missing_base_url))
+ return
+ }
+
+ if (realmName == null) {
+ showToastAndFinish(getString(R.string.error_missing_realm_name))
+ return
+ }
+
+ if (authEndpoint == null) {
+ showToastAndFinish(getString(R.string.error_missing_auth_endpoint))
+ return
+ }
+
+ if (redirectUri == null) {
+ showToastAndFinish(getString(R.string.error_missing_redirect_uri))
+ return
+ }
+
+ if (clientId == null) {
+ showToastAndFinish(getString(R.string.error_missing_client_id))
+ return
+ }
+
+ // Building presenter params
+ val grantType = Constants.GRANT_TYPE_AUTH_CODE
+ val responseType = Constants.RESPONSE_TYPE_CODE
+
+ val authApi = InjectionUtils.getAuthApi(UrlUtils.cleanBaseUrl(baseUrl))
+ val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(applicationContext)
+ val tokenRepo = InjectionUtils.getTokenRepo(
+ authApi, realmName, grantType, redirectUri, clientId, sharedPreferences)
+
+ val gson = Injection.provideGson()
+
+ RedirectPresenter(
+ this, authEndpoint, redirectUri, clientId, responseType, tokenRepo, gson)
+
+ presenter?.subscribe()
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ presenter?.dispose()
+ }
+
+ // Toasts
+ private fun showToastAndFinish(message: String) {
+ Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
+ setResultError(message)
+ finish()
+ }
+
+ // Deep link triggered
+ override fun onNewIntent(intent: Intent?) {
+ super.onNewIntent(intent)
+ checkIntentForRedirect(intent)
+ }
+
+ private fun checkIntentForRedirect(intent: Intent?) {
+ if (intent != null && intent.action == Intent.ACTION_VIEW && intent.data != null) {
+ presenter?.redirectReceived(intent.data.toString())
+ }
+ }
+
+ // Loading
+ override fun showLoading() {
+ progressBar.visibility = View.VISIBLE
+ }
+
+ override fun hideLoading() {
+ progressBar.visibility = View.GONE
+ }
+
+ // Login
+ override fun setUpLoginListener() {
+ loginTv.setOnClickListener {
+ presenter?.loginClicked()
+ }
+ }
+
+ override fun setLoginTextLogin() {
+ loginTv.setText(R.string.login)
+ }
+
+ override fun setLoginTextLoggingIn() {
+ loginTv.setText(R.string.logging_in)
+ }
+
+ /**
+ * Goes to Chrome custom tab
+ */
+ override fun loadWithChrome(url: String) {
+ CustomTabsIntent.Builder()
+ .addDefaultShareMenuItem()
+ .setToolbarColor(ContextCompat.getColor(this, R.color.colorPrimary))
+ .setShowTitle(true)
+ .build()
+ .launchUrl(this, Uri.parse(url))
+ }
+
+ // Results
+ override fun setResultError(errorMessage: String) {
+ val data = Intent()
+ data.putExtra(MobileAuthenticationClient.SUCCESS, false)
+ data.putExtra(MobileAuthenticationClient.ERROR_MESSAGE, errorMessage)
+ setResult(Activity.RESULT_OK, data)
+ }
+
+ override fun setResultSuccess(tokenJson: String) {
+ val data = Intent()
+ data.putExtra(MobileAuthenticationClient.SUCCESS, true)
+ data.putExtra(MobileAuthenticationClient.TOKEN_JSON, tokenJson)
+ setResult(Activity.RESULT_OK, data)
+ }
+}
diff --git a/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/screens/redirect/RedirectContract.kt b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/screens/redirect/RedirectContract.kt
new file mode 100644
index 0000000..215b6d9
--- /dev/null
+++ b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/screens/redirect/RedirectContract.kt
@@ -0,0 +1,36 @@
+package ca.bc.gov.mobileauthentication.screens.redirect
+
+import ca.bc.gov.mobileauthentication.common.BasePresenter
+import ca.bc.gov.mobileauthentication.common.BaseView
+
+/**
+ * Created by Aidan Laing on 2017-12-12.
+ *
+ */
+interface RedirectContract {
+
+ interface View: BaseView {
+ var loading: Boolean
+
+ fun finish()
+
+ fun showLoading()
+ fun hideLoading()
+
+ fun setUpLoginListener()
+ fun setLoginTextLogin()
+ fun setLoginTextLoggingIn()
+
+ fun loadWithChrome(url: String)
+
+ fun setResultError(errorMessage: String)
+ fun setResultSuccess(tokenJson: String)
+ }
+
+ interface Presenter: BasePresenter {
+ fun loginClicked()
+
+ fun redirectReceived(redirectUrl: String)
+ }
+
+}
\ No newline at end of file
diff --git a/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/screens/redirect/RedirectPresenter.kt b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/screens/redirect/RedirectPresenter.kt
new file mode 100644
index 0000000..5906e9a
--- /dev/null
+++ b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/screens/redirect/RedirectPresenter.kt
@@ -0,0 +1,97 @@
+package ca.bc.gov.mobileauthentication.screens.redirect
+
+import ca.bc.gov.mobileauthentication.common.utils.UrlUtils
+import ca.bc.gov.mobileauthentication.data.repos.token.TokenRepo
+import com.google.gson.Gson
+import io.reactivex.android.schedulers.AndroidSchedulers
+import io.reactivex.disposables.CompositeDisposable
+import io.reactivex.rxkotlin.addTo
+import io.reactivex.rxkotlin.subscribeBy
+import io.reactivex.schedulers.Schedulers
+
+/**
+ * Created by Aidan Laing on 2017-12-12.
+ *
+ */
+class RedirectPresenter(
+ private val view: RedirectContract.View,
+ private val authEndpoint: String,
+ private val redirectUri: String,
+ private val clientId: String,
+ private val responseType: String,
+ private val tokenRepo: TokenRepo,
+ private val gson: Gson
+): RedirectContract.Presenter {
+
+ private val disposables = CompositeDisposable()
+
+ init {
+ view.presenter = this
+ }
+
+ override fun subscribe() {
+ setViewLoginMode()
+ view.setUpLoginListener()
+ }
+
+ override fun dispose() {
+ disposables.dispose()
+ }
+
+ override fun loginClicked() {
+ if (!view.loading) view.loadWithChrome(buildAuthUrl())
+ }
+
+ // Auth url
+ fun buildAuthUrl(): String =
+ "$authEndpoint?response_type=$responseType&client_id=$clientId&redirect_uri=$redirectUri"
+
+ // Redirect
+ override fun redirectReceived(redirectUrl: String) {
+ if (!redirectUrl.contains("code=".toRegex())) {
+ return
+ }
+
+ val code = UrlUtils.extractCode(redirectUrl)
+ getToken(code)
+ }
+
+ /**
+ * Gets token remotely using Authorization Code and saves locally
+ */
+ fun getToken(code: String) {
+ tokenRepo.getToken(code)
+ .firstOrError()
+ .subscribeOn(Schedulers.io())
+ .observeOn(AndroidSchedulers.mainThread())
+ .doOnSubscribe {
+ setViewLoadingMode()
+ }.subscribeBy(
+ onError = {
+ setViewLoginMode()
+
+ view.setResultError(it.message ?: "Error logging in")
+ view.finish()
+ },
+ onSuccess = { token ->
+ setViewLoginMode()
+
+ val tokenJson = gson.toJson(token)
+ view.setResultSuccess(tokenJson)
+ view.finish()
+ }
+ ).addTo(disposables)
+ }
+
+ fun setViewLoginMode() {
+ view.loading = false
+ view.hideLoading()
+ view.setLoginTextLogin()
+ }
+
+ fun setViewLoadingMode() {
+ view.loading = true
+ view.showLoading()
+ view.setLoginTextLoggingIn()
+ }
+}
\ No newline at end of file
diff --git a/mobileauthentication/src/main/res/drawable/ic_bc_logo.xml b/mobileauthentication/src/main/res/drawable/ic_bc_logo.xml
new file mode 100644
index 0000000..946d1fd
--- /dev/null
+++ b/mobileauthentication/src/main/res/drawable/ic_bc_logo.xml
@@ -0,0 +1,136 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/mobileauthentication/src/main/res/drawable/selector_rect_color_primary_light_rounded_28dp.xml b/mobileauthentication/src/main/res/drawable/selector_rect_color_primary_light_rounded_28dp.xml
new file mode 100644
index 0000000..d179061
--- /dev/null
+++ b/mobileauthentication/src/main/res/drawable/selector_rect_color_primary_light_rounded_28dp.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/mobileauthentication/src/main/res/drawable/shape_rect_color_primary_light_pressed_rounded_28dp.xml b/mobileauthentication/src/main/res/drawable/shape_rect_color_primary_light_pressed_rounded_28dp.xml
new file mode 100644
index 0000000..b1e408c
--- /dev/null
+++ b/mobileauthentication/src/main/res/drawable/shape_rect_color_primary_light_pressed_rounded_28dp.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/mobileauthentication/src/main/res/drawable/shape_rect_color_primary_light_rounded_28dp.xml b/mobileauthentication/src/main/res/drawable/shape_rect_color_primary_light_rounded_28dp.xml
new file mode 100644
index 0000000..be2fce0
--- /dev/null
+++ b/mobileauthentication/src/main/res/drawable/shape_rect_color_primary_light_rounded_28dp.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/mobileauthentication/src/main/res/layout/activity_login.xml b/mobileauthentication/src/main/res/layout/activity_login.xml
new file mode 100644
index 0000000..4d41546
--- /dev/null
+++ b/mobileauthentication/src/main/res/layout/activity_login.xml
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/mobileauthentication/src/main/res/values/colors.xml b/mobileauthentication/src/main/res/values/colors.xml
new file mode 100644
index 0000000..0dfa5c6
--- /dev/null
+++ b/mobileauthentication/src/main/res/values/colors.xml
@@ -0,0 +1,13 @@
+
+
+
+ #003366
+ #003366
+ #FCBA19
+
+ #5475A7
+ #4b6996
+
+ #FFFFFF
+
+
diff --git a/mobileauthentication/src/main/res/values/strings.xml b/mobileauthentication/src/main/res/values/strings.xml
new file mode 100644
index 0000000..bd2dc0f
--- /dev/null
+++ b/mobileauthentication/src/main/res/values/strings.xml
@@ -0,0 +1,9 @@
+
+ Missing base url intent parameter
+ Missing realm name intent parameter
+ Missing auth endpoint intent parameter
+ Missing redirect uri intent parameter
+ Missing client id intent parameter
+ Login
+ Logging In
+
diff --git a/mobileauthentication/src/main/res/values/styles.xml b/mobileauthentication/src/main/res/values/styles.xml
new file mode 100644
index 0000000..0176218
--- /dev/null
+++ b/mobileauthentication/src/main/res/values/styles.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/mobileauthentication/src/test/java/ca/bc/gov/mobileauthentication/RxImmediateSchedulerRule.kt b/mobileauthentication/src/test/java/ca/bc/gov/mobileauthentication/RxImmediateSchedulerRule.kt
new file mode 100644
index 0000000..e84045b
--- /dev/null
+++ b/mobileauthentication/src/test/java/ca/bc/gov/mobileauthentication/RxImmediateSchedulerRule.kt
@@ -0,0 +1,49 @@
+package ca.bc.gov.mobileauthentication
+
+import org.junit.rules.TestRule
+import org.junit.runner.Description
+import org.junit.runners.model.Statement
+
+import java.util.concurrent.Callable
+
+import io.reactivex.Scheduler
+import io.reactivex.android.plugins.RxAndroidPlugins
+import io.reactivex.functions.Function
+import io.reactivex.plugins.RxJavaPlugins
+import io.reactivex.schedulers.Schedulers
+
+/**
+ * JUnit Test Rule which overrides RxJava and Android schedulers for use in unit tests.
+ *
+ *
+ * All schedulers are replaced with Schedulers.trampoline().
+ */
+class RxImmediateSchedulerRule: TestRule {
+
+ private val schedulerInstance = Schedulers.trampoline()
+
+ private val schedulerFunction = Function { schedulerInstance }
+
+ private val schedulerFunctionLazy = Function, Scheduler> { schedulerInstance }
+
+ override fun apply(base: Statement, description: Description): Statement {
+ return object: Statement() {
+
+ @Throws(Throwable::class)
+ override fun evaluate() {
+ RxAndroidPlugins.reset()
+ RxAndroidPlugins.setInitMainThreadSchedulerHandler(schedulerFunctionLazy)
+
+ RxJavaPlugins.reset()
+ RxJavaPlugins.setIoSchedulerHandler(schedulerFunction)
+ RxJavaPlugins.setNewThreadSchedulerHandler(schedulerFunction)
+ RxJavaPlugins.setComputationSchedulerHandler(schedulerFunction)
+
+ base.evaluate()
+
+ RxAndroidPlugins.reset()
+ RxJavaPlugins.reset()
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/mobileauthentication/src/test/java/ca/bc/gov/mobileauthentication/common/utils/UrlUtilsTest.kt b/mobileauthentication/src/test/java/ca/bc/gov/mobileauthentication/common/utils/UrlUtilsTest.kt
new file mode 100644
index 0000000..0bd2d66
--- /dev/null
+++ b/mobileauthentication/src/test/java/ca/bc/gov/mobileauthentication/common/utils/UrlUtilsTest.kt
@@ -0,0 +1,58 @@
+package ca.bc.gov.mobileauthentication.common.utils
+
+import org.junit.Test
+
+import org.junit.Assert.*
+
+/**
+ * Created by Aidan Laing on 2018-02-05.
+ *
+ */
+class UrlUtilsTest {
+
+ @Test
+ fun cleanBaseUrlNoSlash() {
+ val baseUrl = "http://foobar.com"
+ val expected = "http://foobar.com/"
+ val actual = UrlUtils.cleanBaseUrl(baseUrl)
+
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun cleanBaseUrlSlash() {
+ val baseUrl = "http://foobar.com/"
+ val expected = "http://foobar.com/"
+ val actual = UrlUtils.cleanBaseUrl(baseUrl)
+
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun extractCodeSingleQueryParam() {
+ val codeUrl = "http://foobar.com?code=123"
+ val expected = "123"
+ val actual = UrlUtils.extractCode(codeUrl)
+
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun extractCodeSingleMultipleParam() {
+ val codeUrl = "http://foobar.com?code=123&moo=abc"
+ val expected = "123"
+ val actual = UrlUtils.extractCode(codeUrl)
+
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun extractCodeNoCode() {
+ val codeUrl = "http://foobar.com"
+ val expected = ""
+ val actual = UrlUtils.extractCode(codeUrl)
+
+ assertEquals(expected, actual)
+ }
+
+}
\ No newline at end of file
diff --git a/mobileauthentication/src/test/java/ca/bc/gov/mobileauthentication/data/models/TokenTest.kt b/mobileauthentication/src/test/java/ca/bc/gov/mobileauthentication/data/models/TokenTest.kt
new file mode 100644
index 0000000..8648f7c
--- /dev/null
+++ b/mobileauthentication/src/test/java/ca/bc/gov/mobileauthentication/data/models/TokenTest.kt
@@ -0,0 +1,91 @@
+package ca.bc.gov.mobileauthentication.data.models
+
+import org.junit.Test
+
+import org.junit.Assert.*
+
+/**
+ * Created by Aidan Laing on 2018-02-05.
+ *
+ */
+class TokenTest {
+
+ @Test
+ fun tokenExpiryAfterCurrentTime() {
+ val currentTime = 2000L
+ val expiresAt = 3000L
+ val token = Token(null, null, null, null,
+ null, null, null, null, expiresAt)
+
+ val expected = true
+ val actual = token.isExpired(currentTime)
+
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun tokenExpiryBeforeCurrentTime() {
+ val currentTime = 2000L
+ val expiresAt = 1000L
+ val token = Token(null, null, null, null,
+ null, null, null, null, expiresAt)
+
+ val expected = false
+ val actual = token.isExpired(currentTime)
+
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun tokenExpiryEqualToCurrentTime() {
+ val currentTime = 2000L
+ val expiresAt = 2000L
+ val token = Token(null, null, null, null,
+ null, null, null, null, expiresAt)
+
+ val expected = false
+ val actual = token.isExpired(currentTime)
+
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun refreshExpiryAfterCurrentTime() {
+ val currentTime = 2000L
+ val refreshExpiresAt = 3000L
+ val token = Token(null, null, null, null,
+ null, null, null, null, 0, refreshExpiresAt)
+
+ val expected = true
+ val actual = token.isRefreshExpired(currentTime)
+
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun refreshExpiryBeforeCurrentTime() {
+ val currentTime = 2000L
+ val refreshExpiresAt = 1000L
+ val token = Token(null, null, null, null,
+ null, null, null, null, 0, refreshExpiresAt)
+
+ val expected = false
+ val actual = token.isRefreshExpired(currentTime)
+
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun refreshExpiryEqualToCurrentTime() {
+ val currentTime = 2000L
+ val refreshExpiresAt = 2000L
+ val token = Token(null, null, null, null,
+ null, null, null, null, 0, refreshExpiresAt)
+
+ val expected = false
+ val actual = token.isRefreshExpired(currentTime)
+
+ assertEquals(expected, actual)
+ }
+
+}
\ No newline at end of file
diff --git a/mobileauthentication/src/test/java/ca/bc/gov/mobileauthentication/screens/redirect/RedirectPresenterTest.kt b/mobileauthentication/src/test/java/ca/bc/gov/mobileauthentication/screens/redirect/RedirectPresenterTest.kt
new file mode 100644
index 0000000..bc104c9
--- /dev/null
+++ b/mobileauthentication/src/test/java/ca/bc/gov/mobileauthentication/screens/redirect/RedirectPresenterTest.kt
@@ -0,0 +1,161 @@
+package ca.bc.gov.mobileauthentication.screens.redirect
+
+import ca.bc.gov.mobileauthentication.RxImmediateSchedulerRule
+import ca.bc.gov.mobileauthentication.data.models.Token
+import ca.bc.gov.mobileauthentication.data.repos.token.TokenRepo
+import com.google.gson.Gson
+import com.nhaarman.mockito_kotlin.*
+import io.reactivex.Observable
+import org.junit.After
+import org.junit.Test
+
+import org.junit.Before
+import org.junit.ClassRule
+import org.junit.Assert.*
+
+/**
+ * Created by Aidan Laing on 2018-02-05.
+ *
+ */
+class RedirectPresenterTest {
+
+ companion object {
+ @ClassRule
+ @JvmField
+ val rxSchedulers = RxImmediateSchedulerRule()
+ }
+
+ private lateinit var view: RedirectContract.View
+
+ private lateinit var tokenRepo: TokenRepo
+
+ private lateinit var gson: Gson
+
+ private val authEndpoint = "http://helloworld.com"
+ private val redirectUri = "hello://world"
+ private val clientId = "abc123"
+ private val responseType = "code"
+
+ private lateinit var presenter: RedirectPresenter
+
+ @Before
+ fun setUp() {
+ view = mock()
+
+ tokenRepo = mock()
+
+ gson = mock()
+
+ presenter = RedirectPresenter(view, authEndpoint, redirectUri, clientId, responseType,
+ tokenRepo, gson)
+ }
+
+ @After
+ fun tearDown() {
+ TokenRepo.destroyInstance()
+ }
+
+ @Test
+ fun presenterSet() {
+ verify(view).presenter = presenter
+ }
+
+ @Test
+ fun subscribe() {
+ presenter.subscribe()
+
+ verify(view).setUpLoginListener()
+ }
+
+ @Test
+ fun loginClickedNotLoading() {
+ whenever(view.loading).thenReturn(false)
+
+ presenter.loginClicked()
+
+ verify(view).loading
+ verify(view).loadWithChrome(any())
+ }
+
+ @Test
+ fun loginClickedLoading() {
+ whenever(view.loading).thenReturn(true)
+
+ presenter.loginClicked()
+
+ verify(view).loading
+ verify(view).presenter = presenter
+ verifyNoMoreInteractions(view)
+ }
+
+ @Test
+ fun buildAuthUrl() {
+ val expected = "$authEndpoint?response_type=$responseType&client_id=$clientId&redirect_uri=$redirectUri"
+ val actual = presenter.buildAuthUrl()
+
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun redirectReceived() {
+ val code = "123"
+ val token = Token("opensesame",null,null,null,
+ null,null,null,null)
+ whenever(tokenRepo.getToken(code)).thenReturn(Observable.just(token))
+
+ val tokenJson = "{ \"accessToken\" : \"opensesame\"}"
+ whenever(gson.toJson(token)).thenReturn(tokenJson)
+
+ val redirectUrl = "http://www.foobar.com?code=$code"
+
+ presenter.redirectReceived(redirectUrl)
+
+ verify(view).setResultSuccess(tokenJson)
+ verify(view).finish()
+ }
+
+ @Test
+ fun redirectReceivedNoCode() {
+ val redirectUrl = "http://www.foobar.com"
+
+ presenter.redirectReceived(redirectUrl)
+
+ verify(view).presenter = presenter
+ verifyNoMoreInteractions(view)
+ }
+
+ @Test
+ fun getToken() {
+ val code = "123"
+ val token = Token("opensesame",null,null,null,
+ null,null,null,null)
+ whenever(tokenRepo.getToken(code)).thenReturn(Observable.just(token))
+
+ val tokenJson = "{ \"accessToken\" : \"opensesame\"}"
+ whenever(gson.toJson(token)).thenReturn(tokenJson)
+
+ presenter.getToken(code)
+
+ verify(view).setResultSuccess(tokenJson)
+ verify(view).finish()
+ }
+
+ @Test
+ fun setViewLoginMode() {
+ presenter.setViewLoginMode()
+
+ verify(view).loading = false
+ verify(view).hideLoading()
+ verify(view).setLoginTextLogin()
+ }
+
+ @Test
+ fun setViewLoadingMode() {
+ presenter.setViewLoadingMode()
+
+ verify(view).loading = true
+ verify(view).showLoading()
+ verify(view).setLoginTextLoggingIn()
+ }
+
+}
\ No newline at end of file
diff --git a/mobileauthentication/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/mobileauthentication/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker
new file mode 100644
index 0000000..ca6ee9c
--- /dev/null
+++ b/mobileauthentication/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker
@@ -0,0 +1 @@
+mock-maker-inline
\ No newline at end of file
diff --git a/settings.gradle b/settings.gradle
index e7b4def..1861058 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -1 +1 @@
-include ':app'
+include ':app', ':mobileauthentication'