├── .gitignore ├── LICENSE ├── README.md ├── app ├── .gitignore ├── build.gradle ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── net │ │ └── paonejp │ │ └── kndzyb │ │ └── appauthdemo │ │ └── ExampleInstrumentedTest.kt │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ └── net │ │ │ └── paonejp │ │ │ └── kndzyb │ │ │ └── appauthdemo │ │ │ ├── MainActivity.kt │ │ │ └── util │ │ │ ├── Crypto.kt │ │ │ └── HttpRequestJsonTask.kt │ └── res │ │ ├── drawable │ │ └── ic_launcher_background.xml │ │ ├── layout │ │ └── activity_main.xml │ │ ├── mipmap-anydpi-v26 │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ │ ├── mipmap-hdpi │ │ ├── ic_launcher.png │ │ ├── ic_launcher_foreground.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-mdpi │ │ ├── ic_launcher.png │ │ ├── ic_launcher_foreground.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xhdpi │ │ ├── ic_launcher.png │ │ ├── ic_launcher_foreground.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxhdpi │ │ ├── ic_launcher.png │ │ ├── ic_launcher_foreground.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxxhdpi │ │ ├── ic_launcher.png │ │ ├── ic_launcher_foreground.png │ │ └── ic_launcher_round.png │ │ ├── values-ja-rJP │ │ └── strings.xml │ │ └── values │ │ ├── colors.xml │ │ ├── strings.xml │ │ └── styles.xml │ └── test │ └── java │ └── net │ └── paonejp │ └── kndzyb │ └── appauthdemo │ └── ExampleUnitTest.kt ├── build.gradle ├── built ├── app-release.apk ├── app-release.apk.sha1 └── app-release.apk.sha256 ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat └── settings.gradle /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea/workspace.xml 5 | /.idea/libraries 6 | .DS_Store 7 | /build 8 | /captures 9 | .externalNativeBuild 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017, Takashi Yahata (@paoneJP). 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Kotlin + AppAuth for Android ネイティブアプリ実装サンプル 2 | ========================================================= 3 | 4 | これは OAuth 2.0 で保護されたバックエンドAPIを使用する Android ネイティブアプリの実装サンプルです。 5 | 6 | ネイティブアプリでの OAuth 2.0 の実装については [RFC 8252][BCP 212] OAuth 2.0 for Native Apps として現時点のベストプラクティスがまとめられています。 7 | また、そのプラクティスに沿った実装を支援するオープンソースライブラリ AppAuth が公開されており、今回はその Android 用のライブラリ AppAuth for Android を用いて実装してみます。 8 | 9 | 10 | ## 開発環境 11 | 12 | * Android Studio 3.0 13 | * Kotlin 1.1 14 | * AppAuth for Android 0.7.0 15 | * API Level 21以上 (Android 5.0以上) 16 | 17 | 18 | ## 実装されている機能 19 | 20 | * Google Accounts 使ったサインイン (OAuth 2.0 Authorization) 21 | * Google Accounts の UserInfo エンドポイントをバックエンドAPIに見立てたAPIアクセス 22 | * リフレッシュトークンを使ったアクセストークンの更新 23 | * アクセストークン、リフレッシュトークンの SharedPreferences への保存とその際の暗号化 24 | * サインアウトとその際のトークン失効 (OAuth 2.0 Token Revocation) 25 | * その他補助的な機能として以下の2つを実装 26 | - 強制的にアクセストークンを更新 27 | 28 | 29 | ## 遊び方 30 | 31 | * Google Cloud Platform の「APIとサービス」でプロジェクトを作成します。 32 | * 「認証情報を作成」で OAuthクライアントID を作成します。この時アプリケーションの種類は Android を選択します。 33 | * 発行されたクライアントIDを確認します。 34 | 35 | * Android Studio でこのディレクトリをプロジェクトとして開きます。 36 | * `MainActivity.kt` の `CLIENT_ID` の値に、先に確認したクライアントIDの値を設定し、ビルド、実行します。 37 | 38 | 取り急ぎ動作の確認をしたい方のために、ビルド済みの apk ファイルを `built` ディレクトリに収録しています。 39 | 40 | 41 | ## 操作 42 | 43 | * 「サインイン」 44 | - Google Accounts で認証を行ないアクセストークン、リフレッシュトークンを取得します。 45 | - AppAuth の内部状態を appAuthState (Summary) および appAuthState (Full) エリアに表示します。 46 | * 「API呼出し」 47 | - Google Accounts の UserInfo エンドポイントへAPIアクセスし、その結果を Response エリアに表示します。 48 | * 「認証状態表示」 49 | - AppAuth の内部状態を appAuthStatre (Summary) および appAuthState (Full) エリアに表示します。 50 | * 「サインアウト」 51 | - Google Accounts にアクセストークン、リフレッシュトークンの失効を要求し、AppAuth の内部状態を初期化します。 52 | * 「トークン強制更新」 53 | - AppAuth の内部状態の `needsTokenRefresh` を `true` にしてAPI呼出しを実行します。 54 | アクセストークンが強制的に更新されてからAPIが呼び出されます。 55 | 56 | ネットワークアクセスができない状況や、 Google Accouns の「アカウントにアクセスできるアプリ」でアクセス権を削除した状態などで動作を試してみると良いでしょう。 57 | 58 | 59 | ## 実装上のポイント 60 | 61 | ### 認証 (OAuth 2.0 Authorization) の要求 62 | 63 | * `startAuthorization()` を呼び出すことで、 Chrome Custom Tabs あるいは外部ブラウザが起動され、 Google Accounts の認証画面が表示されます。 64 | 65 | ### アクセストークンの取得 66 | 67 | * 認証が完了すると `redirect_uri` へ結果が返却されます。 68 | * `onActivityResult()` で `redirect_uri` に返された結果を受け取ります。 69 | * 受け取った結果を `handleAuthorizationResponse()` で処理し、アクセストークンの取得を完了します。 70 | * 認証の処理が成功すれば `whenAuthorizationSucceeds()` が、失敗すれば `whenAuthorizationFails()` が呼び出されます。 71 | 72 | ### バックエンドAPIへのアクセス 73 | 74 | * APIの呼出しは `httpGetJson()` で行ないます。 75 | * AppAuth の `performActionWithFreshTokens()` を使うことで、アクセストークンを自動更新しながらAPIアクセスを行ないます。 76 | * 実行結果は `httpGetJson()` の callback 引数に渡されたコールバック関数で処理します。 77 | * コールバック関数では、(1) API呼出しが成功、(2) API呼出し時にエラーが発生、(3) 再認証が必要な状態である の3つに場合分けをして処理を行なっています。 78 | 79 | ### アクセストークンの保存 (AppAuthの状態の保存) 80 | 81 | * 画面の切り替え等が生じたときのための状態の保存は `onSaveInstanceState()` で行ない、それを `onCreate()` で復元しています。 82 | * SharedPreferences への保存は `onPause()` で行ない、 `onCreate()` で `savedInstanceState` が無いときに SharedPreferences の内容を復元しています。 83 | * アクセストークン、リフレッシュトークンの暗号化の処理は `encryptString()` と `decryptString()` で行なっています。 84 | * 暗号の鍵管理には Android KeyStore System を使っています。 API Level により扱えるアルゴリズムに違いがあるため、 API Level 23 以上と API Level 21, 22 で動作を変えて対応しています。 85 | 86 | ### アクセストークンの失効 87 | 88 | * `revokeAuthorization()` を呼び出した際に、Google Accounts にトークン失効を要求します。 89 | * トークンの失効ができれば AppAuth の内部状態を初期化し、サインイン前の状態とします。 90 | * トークンの失効中にエラーが生じた場合は、サインイン状態を保つようにしています。 91 | 92 | 93 | ## 参考サイト 94 | 95 | * [RFC 8252][BCP 212] OAuth 2.0 for Native Apps 96 | - https://datatracker.ietf.org/doc/rfc8252/ 97 | - https://datatracker.ietf.org/doc/bcp212/ 98 | * AppAuth for Android 99 | - https://openid.net/code/AppAuth 100 | - https://openid.net/code/AppAuth-Android 101 | 102 | 103 | ## ライセンス等 104 | 105 | * この実装サンプルのオリジナルは、以下の場所で MIT License で公開しています。 106 | - https://github.com/paoneJP/AppAuthDemo-Android 107 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /app/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.application' 2 | apply plugin: 'kotlin-android' 3 | apply plugin: 'kotlin-android-extensions' 4 | android { 5 | signingConfigs { 6 | } 7 | compileSdkVersion 25 8 | buildToolsVersion '26.0.2' 9 | defaultConfig { 10 | applicationId "net.paonejp.kndzyb.appauthdemo" 11 | minSdkVersion 21 12 | targetSdkVersion 25 13 | versionCode 1 14 | versionName "1.0" 15 | testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" 16 | } 17 | buildTypes { 18 | release { 19 | minifyEnabled false 20 | proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' 21 | } 22 | } 23 | productFlavors { 24 | } 25 | } 26 | dependencies { 27 | implementation fileTree(include: ['*.jar'], dir: 'libs') 28 | implementation 'com.android.support:appcompat-v7:25.3.1' 29 | implementation 'com.android.support.constraint:constraint-layout:1.0.2' 30 | testImplementation 'junit:junit:4.12' 31 | androidTestImplementation('com.android.support.test.espresso:espresso-core:3.0.1', { 32 | exclude group: 'com.android.support', module: 'support-annotations' 33 | }) 34 | implementation "org.jetbrains.kotlin:kotlin-stdlib-jre7:$kotlin_version" 35 | implementation 'net.openid:appauth:0.7.0' 36 | } 37 | 38 | repositories { 39 | maven { url 'https://dl.bintray.com/openid/net.openid' } 40 | } -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile 22 | -------------------------------------------------------------------------------- /app/src/androidTest/java/net/paonejp/kndzyb/appauthdemo/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package net.paonejp.kndzyb.appauthdemo 2 | 3 | import android.support.test.InstrumentationRegistry 4 | import android.support.test.runner.AndroidJUnit4 5 | 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | import org.junit.Assert.* 10 | 11 | /** 12 | * Instrumented test, which will execute on an Android device. 13 | * 14 | * See [testing documentation](http://d.android.com/tools/testing). 15 | */ 16 | @RunWith(AndroidJUnit4::class) 17 | class ExampleInstrumentedTest { 18 | @Test 19 | fun useAppContext() { 20 | // Context of the app under test. 21 | val appContext = InstrumentationRegistry.getTargetContext() 22 | assertEquals("net.paonejp.kndzyb.appauthdemo", appContext.packageName) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | 8 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /app/src/main/java/net/paonejp/kndzyb/appauthdemo/MainActivity.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * AppAuth for Android demonstration application. 3 | * Author: Takashi Yahata (@paoneJP) 4 | * Copyright: (c) 2017 Takashi Yahata 5 | * License: MIT License 6 | */ 7 | 8 | package net.paonejp.kndzyb.appauthdemo 9 | 10 | import android.content.Intent 11 | import android.net.Uri 12 | import android.os.Bundle 13 | import android.support.v7.app.AppCompatActivity 14 | import android.util.Log 15 | import android.view.View 16 | import kotlinx.android.synthetic.main.activity_main.* 17 | import net.openid.appauth.* 18 | import net.paonejp.kndzyb.appauthdemo.util.HttpRequestJsonTask 19 | import net.paonejp.kndzyb.appauthdemo.util.decryptString 20 | import net.paonejp.kndzyb.appauthdemo.util.encryptString 21 | import org.json.JSONException 22 | import org.json.JSONObject 23 | import java.io.IOException 24 | import java.net.HttpURLConnection.* 25 | import java.text.DateFormat 26 | import java.util.* 27 | 28 | 29 | private val ISSUER_URI = "https://accounts.google.com" 30 | private val CLIENT_ID = "999999999999-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.apps.googleusercontent.com" 31 | private val REDIRECT_URI = "net.paonejp.kndzyb.appauthdemo:/cb" 32 | 33 | private val SCOPE = "profile" 34 | private val API_URI = "https://www.googleapis.com/oauth2/v3/userinfo" 35 | 36 | private val REQCODE_AUTH = 100 37 | private val X_HTTP_NEED_REAUTHZ = -1 38 | private val X_HTTP_ERROR = -9 39 | 40 | private val LOG_TAG = "MainActivity" 41 | 42 | 43 | class MainActivity : AppCompatActivity() { 44 | 45 | private lateinit var appAuthState: AuthState 46 | 47 | override fun onCreate(savedInstanceState: Bundle?) { 48 | super.onCreate(savedInstanceState) 49 | setContentView(R.layout.activity_main) 50 | 51 | if (savedInstanceState != null) { 52 | try { 53 | appAuthState = savedInstanceState 54 | .getString("appAuthState", "{}") 55 | .let { AuthState.jsonDeserialize(it) } 56 | } catch (ex: JSONException) { 57 | val m = Throwable().stackTrace[0] 58 | Log.e(LOG_TAG, "${m}: ${ex}") 59 | appAuthState = AuthState() 60 | } 61 | } else { 62 | val prefs = getSharedPreferences("appAuthPreference", MODE_PRIVATE) 63 | val data = decryptString(this, prefs.getString("appAuthState", null)) ?: "{}" 64 | appAuthState = AuthState.jsonDeserialize(data) 65 | } 66 | 67 | if (savedInstanceState != null) { 68 | uAppAuthStateView.text = savedInstanceState.getCharSequence("appAuthStateView") 69 | uResponseView.text = savedInstanceState.getCharSequence("responseView") 70 | uAppAuthStateFullView.text = savedInstanceState.getCharSequence("appAuthStateFullView") 71 | } else { 72 | doShowAppAuthState() 73 | uResponseView.text = getText(R.string.msg_app_start) 74 | } 75 | 76 | } 77 | 78 | override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { 79 | super.onActivityResult(requestCode, resultCode, data) 80 | if (requestCode == REQCODE_AUTH) { 81 | handleAuthorizationResponse(data) 82 | } 83 | } 84 | 85 | override fun onSaveInstanceState(outState: Bundle?) { 86 | super.onSaveInstanceState(outState) 87 | 88 | outState?.putString("appAuthState", appAuthState.jsonSerializeString()) 89 | 90 | outState?.putCharSequence("appAuthStateView", uAppAuthStateView.text) 91 | outState?.putCharSequence("responseView", uResponseView.text) 92 | outState?.putCharSequence("appAuthStateFullView", uAppAuthStateFullView.text) 93 | } 94 | 95 | override fun onPause() { 96 | super.onPause() 97 | 98 | getSharedPreferences("appAuthPreference", MODE_PRIVATE) 99 | .edit() 100 | .putString("appAuthState", encryptString(this, appAuthState.jsonSerializeString())) 101 | .apply() 102 | } 103 | 104 | 105 | fun onClickSigninButton(view: View) { 106 | startAuthorization() 107 | } 108 | 109 | fun onClickSignoutButton(view: View) { 110 | revokeAuthorization() 111 | } 112 | 113 | fun onClickCallApiButton(view: View) { 114 | showApiResult() 115 | } 116 | 117 | fun onClickShowStatusButton(view: View) { 118 | showAppAuthStatus() 119 | } 120 | 121 | fun onClickTokenRefreshButton(view: View) { 122 | tokenRefreshAndShowApiResult() 123 | } 124 | 125 | 126 | private fun startAuthorization() { 127 | AuthorizationServiceConfiguration.fetchFromIssuer(Uri.parse(ISSUER_URI), { config, ex -> 128 | if (config != null) { 129 | val req = AuthorizationRequest 130 | .Builder(config, CLIENT_ID, ResponseTypeValues.CODE, Uri.parse(REDIRECT_URI)) 131 | .setScope(SCOPE) 132 | .build() 133 | val intent = AuthorizationService(this).getAuthorizationRequestIntent(req) 134 | startActivityForResult(intent, REQCODE_AUTH) 135 | } else { 136 | if (ex != null) { 137 | val m = Throwable().stackTrace[0] 138 | Log.e(LOG_TAG, "${m}: ${ex}") 139 | } 140 | whenAuthorizationFails(ex) 141 | } 142 | }) 143 | } 144 | 145 | private fun handleAuthorizationResponse(data: Intent?) { 146 | if (data == null) { 147 | val m = Throwable().stackTrace[0] 148 | Log.e(LOG_TAG, "${m}: unexpected intent call") 149 | return 150 | } 151 | 152 | val resp = AuthorizationResponse.fromIntent(data) 153 | val ex = AuthorizationException.fromIntent(data) 154 | appAuthState.update(resp, ex) 155 | 156 | if (ex != null || resp == null) { 157 | val m = Throwable().stackTrace[0] 158 | Log.e(LOG_TAG, "${m}: ${ex}") 159 | whenAuthorizationFails(ex) 160 | return 161 | } 162 | 163 | AuthorizationService(this) 164 | .performTokenRequest(resp.createTokenExchangeRequest(), { resp2, ex2 -> 165 | appAuthState.update(resp2, ex2) 166 | if (resp2 != null) { 167 | whenAuthorizationSucceeds() 168 | } else { 169 | whenAuthorizationFails(ex2) 170 | } 171 | }) 172 | } 173 | 174 | // 認証成功時の処理を書く。 175 | // Write program to be executed when authorization succeeds. 176 | private fun whenAuthorizationSucceeds() { 177 | uResponseView.text = getText(R.string.msg_auth_ok) 178 | doShowAppAuthState() 179 | } 180 | 181 | // 認証エラー時の処理を書く。 182 | // Write program to be executed when authorization fails. 183 | private fun whenAuthorizationFails(ex: AuthorizationException?) { 184 | uResponseView.text = "%s\n\n%s".format(getText(R.string.msg_auth_ng), ex?.message) 185 | doShowAppAuthState() 186 | } 187 | 188 | 189 | private fun revokeAuthorization() { 190 | val uri = appAuthState.authorizationServiceConfiguration?.discoveryDoc?.docJson 191 | ?.opt("revocation_endpoint") as String? 192 | 193 | if (uri == null) { 194 | appAuthState = AuthState() 195 | whenRevokeAuthorizationSucceeds() 196 | return 197 | } 198 | 199 | val param = "token=${appAuthState.refreshToken}&token_type_hint=refresh_token" 200 | HttpRequestJsonTask(uri, param, null, { code, data, ex -> 201 | when (code) { 202 | HTTP_OK -> { 203 | appAuthState = AuthState() 204 | whenRevokeAuthorizationSucceeds() 205 | } 206 | 207 | HTTP_BAD_REQUEST -> { 208 | 209 | // RFC 7009 に示されているように、すでに無効なトークンの無効化リクエストに 210 | // 対しサーバーは HTTP 200 を応答するが、一部のサーバーはエラーを応答する 211 | // ことがある。Google Accounts の場合、 HTTP 400 で "invalid_token" エラー 212 | // を返すため、それを成功応答として処理する。 213 | // As described in RFC 7009, the server responds with HTTP 200 for revocation 214 | // request to already invalidated token, but some servers may respond with an 215 | // error. Google Accounts returns "invalid_token" error with HTTP 400, it must 216 | // be treated as a successful response. 217 | if (data?.optString("error") == "invalid_token") { 218 | appAuthState = AuthState() 219 | whenRevokeAuthorizationSucceeds() 220 | return@HttpRequestJsonTask 221 | } 222 | 223 | val msg = "Server returned HTTP response code: %d for URL: %s with message: %s" 224 | .format(code, uri, data.toString()) 225 | whenRevokeAuthorizationFails(IOException(msg)) 226 | } 227 | 228 | else -> whenRevokeAuthorizationFails(ex) 229 | } 230 | }).execute() 231 | } 232 | 233 | // 認証状態取り消し時の処理を書く。 234 | // Write program to be executed when revoking authorization succeeds. 235 | private fun whenRevokeAuthorizationSucceeds() { 236 | uResponseView.text = getText(R.string.msg_auth_revoke_ok) 237 | doShowAppAuthState() 238 | } 239 | 240 | // 認証状態取り消し時の処理を書く。 241 | // Write program to be executed when revoking authorization fails. 242 | private fun whenRevokeAuthorizationFails(ex: Exception?) { 243 | uResponseView.text = "%s\n\n%s" 244 | .format(getText(R.string.msg_auth_revoke_ng), ex ?: "") 245 | doShowAppAuthState() 246 | } 247 | 248 | 249 | private fun showAppAuthStatus() { 250 | uResponseView.text = getText(R.string.msg_show_auth_state) 251 | doShowAppAuthState() 252 | } 253 | 254 | 255 | private fun doShowAppAuthState() { 256 | val t = appAuthState 257 | .accessTokenExpirationTime 258 | ?.let { DateFormat.getDateTimeInstance().format(Date(it)) } 259 | uAppAuthStateView.text = JSONObject() 260 | .putOpt("isAuthorized", appAuthState.isAuthorized) 261 | .putOpt("accessToken", appAuthState.accessToken) 262 | .putOpt("accessTokenExpirationTime", appAuthState.accessTokenExpirationTime) 263 | .putOpt("accessTokenExpirationTime_readable", t) 264 | .putOpt("refreshToken", appAuthState.refreshToken) 265 | .putOpt("needsTokenRefresh", appAuthState.needsTokenRefresh) 266 | .toString(2) 267 | .replace("\\/", "/") 268 | uAppAuthStateFullView.text = appAuthState 269 | .jsonSerialize() 270 | .toString(2) 271 | .replace("\\/", "/") 272 | uScrollView.scrollY = 0 273 | } 274 | 275 | 276 | // APIを呼び出す処理を書く。 277 | // Write program calling the API. 278 | private fun showApiResult() { 279 | httpGetJson(API_URI, { code, data, ex -> showApiResultCallback(code, data, ex) }) 280 | } 281 | 282 | private fun tokenRefreshAndShowApiResult() { 283 | appAuthState.needsTokenRefresh = true 284 | showApiResult() 285 | } 286 | 287 | // APIレスポンスに対する処理を書く。 288 | // Write program to be executed when API responded. 289 | private fun showApiResultCallback(code: Int, data: JSONObject?, ex: Exception?) { 290 | when (code) { 291 | X_HTTP_NEED_REAUTHZ, HTTP_UNAUTHORIZED -> whenReauthorizationRequired(ex) 292 | 293 | HTTP_OK -> { 294 | uResponseView.text = "%s\n\n%s" 295 | .format(getText(R.string.msg_api_ok), 296 | data?.toString(2)?.replace("\\/", "/")) 297 | } 298 | 299 | else -> { 300 | uResponseView.text = "%s\n\n%d\n%s\n%s" 301 | .format(getText(R.string.msg_api_error), 302 | code, data ?: "", ex ?: "") 303 | } 304 | } 305 | doShowAppAuthState() 306 | } 307 | 308 | // 再認証が必要な状態の時の処理を書く。 309 | // Write program to be executed when reauthorization required. 310 | private fun whenReauthorizationRequired(ex: Exception?) { 311 | uResponseView.text = "%s\n\n%s" 312 | .format(getText(R.string.msg_reauthz_required), ex ?: "") 313 | doShowAppAuthState() 314 | } 315 | 316 | 317 | private fun httpGetJson(uri: String, 318 | callback: (code: Int, json: JSONObject?, ex: Exception?) -> Unit) { 319 | val service = AuthorizationService(this) 320 | appAuthState.performActionWithFreshTokens(service, { accessToken, _, ex -> 321 | if (ex != null) { 322 | val m = Throwable().stackTrace[0] 323 | Log.e(LOG_TAG, "${m}: ${ex}") 324 | if (appAuthState.isAuthorized) { 325 | callback(X_HTTP_ERROR, null, ex) 326 | } else { 327 | callback(X_HTTP_NEED_REAUTHZ, null, ex) 328 | } 329 | } else { 330 | if (accessToken == null) { 331 | callback(X_HTTP_ERROR, null, null) 332 | } else { 333 | HttpRequestJsonTask(uri, null, accessToken, { code, data, ex2 -> 334 | callback(code, data, ex2) 335 | }).execute() 336 | } 337 | } 338 | }) 339 | } 340 | 341 | } 342 | -------------------------------------------------------------------------------- /app/src/main/java/net/paonejp/kndzyb/appauthdemo/util/Crypto.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * AppAuth for Android demonstration application. 3 | * Author: Takashi Yahata (@paoneJP) 4 | * Copyright: (c) 2017 Takashi Yahata 5 | * License: MIT License 6 | */ 7 | 8 | package net.paonejp.kndzyb.appauthdemo.util 9 | 10 | import android.annotation.TargetApi 11 | import android.content.Context 12 | import android.os.Build 13 | import android.security.KeyPairGeneratorSpec 14 | import android.security.keystore.KeyGenParameterSpec 15 | import android.security.keystore.KeyProperties 16 | import android.support.v7.app.AppCompatActivity 17 | import android.util.Base64 18 | import android.util.Log 19 | import java.math.BigInteger 20 | import java.security.KeyPair 21 | import java.security.KeyPairGenerator 22 | import java.security.KeyStore 23 | import java.security.SecureRandom 24 | import java.util.* 25 | import javax.crypto.Cipher 26 | import javax.crypto.KeyGenerator 27 | import javax.crypto.SecretKey 28 | import javax.crypto.spec.IvParameterSpec 29 | import javax.crypto.spec.SecretKeySpec 30 | import javax.security.auth.x500.X500Principal 31 | 32 | 33 | private val DATA_ENC_KEY_ALIAS = "data_encrytpion_key" 34 | 35 | private val KEY_ENC_KEY_ALIAS = "key_encryption_key" 36 | private val KEY_ENC_KEY_SUBJECT = "CN=net.paonejp.kndzyb.appauthdemo" 37 | private val KEY_ENC_KEY_VALIDITY_YEARS = 10 38 | private val KEY_ENC_KEY_SERIAL_NUMBER = 1L 39 | 40 | private val BASE64_FLAGS = Base64.NO_WRAP + Base64.NO_PADDING 41 | 42 | private val LOG_TAG = "Crypto" 43 | 44 | 45 | fun encryptString(context: Context, data: String?): String? { 46 | if (data == null) { 47 | return null 48 | } 49 | try { 50 | val key = getDataEncryptionKey(context) 51 | val c = Cipher.getInstance("AES/CBC/PKCS7Padding") 52 | c.init(Cipher.ENCRYPT_MODE, key) 53 | return "%s.%s".format( 54 | Base64.encodeToString(c.iv, BASE64_FLAGS), 55 | Base64.encodeToString(c.doFinal(data.toByteArray()), BASE64_FLAGS)) 56 | } catch (ex: Exception) { 57 | val m = Throwable().stackTrace[0] 58 | Log.e(LOG_TAG, "${m}: ${ex}") 59 | return null 60 | } 61 | } 62 | 63 | 64 | fun decryptString(context: Context, data: String?): String? { 65 | if (data == null) { 66 | return null 67 | } 68 | try { 69 | val d = data.split(Regex("\\."), 2) 70 | val key = getDataEncryptionKey(context) 71 | val c = Cipher.getInstance("AES/CBC/PKCS7Padding") 72 | c.init(Cipher.DECRYPT_MODE, key, 73 | IvParameterSpec(Base64.decode(d[0].toByteArray(), BASE64_FLAGS))) 74 | return String(c.doFinal(Base64.decode(d[1].toByteArray(), BASE64_FLAGS))) 75 | } catch (ex: Exception) { 76 | val m = Throwable().stackTrace[0] 77 | Log.e(LOG_TAG, "${m}: ${ex}") 78 | return null 79 | } 80 | } 81 | 82 | 83 | private fun getDataEncryptionKey(context: Context): SecretKey { 84 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { 85 | return getDataEncryptionKeyAPI23orHigher() 86 | } else { 87 | return getDataEncryptionKeyAPI22orLower(context) 88 | } 89 | } 90 | 91 | 92 | @TargetApi(Build.VERSION_CODES.M) 93 | private fun getDataEncryptionKeyAPI23orHigher(): SecretKey { 94 | val ks = KeyStore.getInstance("AndroidKeyStore") 95 | ks.load(null) 96 | if (ks.isKeyEntry(DATA_ENC_KEY_ALIAS)) { 97 | val ke = ks.getEntry(DATA_ENC_KEY_ALIAS, null) as KeyStore.SecretKeyEntry 98 | return ke.secretKey 99 | } else { 100 | val kg = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore") 101 | kg.init(KeyGenParameterSpec 102 | .Builder(DATA_ENC_KEY_ALIAS, 103 | KeyProperties.PURPOSE_ENCRYPT + KeyProperties.PURPOSE_DECRYPT) 104 | .setKeySize(256) 105 | .setBlockModes(KeyProperties.BLOCK_MODE_CBC) 106 | .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_PKCS7) 107 | .build()) 108 | val key = kg.generateKey() 109 | val m = Throwable().stackTrace[0] 110 | Log.i(LOG_TAG, "${m}: A new data encryption key was generated.") 111 | return key 112 | } 113 | } 114 | 115 | 116 | private fun getDataEncryptionKeyAPI22orLower(context: Context): SecretKey { 117 | 118 | fun getKeyEncryptionKeyPair(): KeyPair? { 119 | val ks = KeyStore.getInstance("AndroidKeyStore") 120 | ks.load(null) 121 | if (ks.isKeyEntry(KEY_ENC_KEY_ALIAS)) { 122 | val ke = ks.getEntry(KEY_ENC_KEY_ALIAS, null) as KeyStore.PrivateKeyEntry 123 | return KeyPair(ke.certificate.publicKey, ke.privateKey) 124 | } else { 125 | val kpg = KeyPairGenerator.getInstance("RSA", "AndroidKeyStore") 126 | val startDate = Calendar.getInstance() 127 | val endDate = Calendar.getInstance() 128 | endDate.add(Calendar.YEAR, KEY_ENC_KEY_VALIDITY_YEARS) 129 | kpg.initialize( 130 | KeyPairGeneratorSpec 131 | .Builder(context) 132 | .setAlias(KEY_ENC_KEY_ALIAS) 133 | .setKeySize(2048) 134 | .setSubject(X500Principal(KEY_ENC_KEY_SUBJECT)) 135 | .setSerialNumber(BigInteger.valueOf(KEY_ENC_KEY_SERIAL_NUMBER)) 136 | .setStartDate(startDate.time) 137 | .setEndDate(endDate.time) 138 | .build()) 139 | val kp = kpg.generateKeyPair() 140 | val m = Throwable().stackTrace[0] 141 | Log.i(LOG_TAG, "${m}: A new key encryption key pair was generated.") 142 | return kp 143 | } 144 | } 145 | 146 | fun encryptAesKey(key: ByteArray): String? { 147 | try { 148 | val kp = getKeyEncryptionKeyPair() 149 | val c = Cipher.getInstance("RSA/ECB/PKCS1Padding") 150 | c.init(Cipher.ENCRYPT_MODE, kp?.public) 151 | return Base64.encodeToString(c.doFinal(key), BASE64_FLAGS) 152 | } catch (ex: Exception) { 153 | val m = Throwable().stackTrace[0] 154 | Log.e(LOG_TAG, "${m}: ${ex}") 155 | return null 156 | } 157 | } 158 | 159 | fun decryptAesKey(key: String?): ByteArray? { 160 | try { 161 | val kp = getKeyEncryptionKeyPair() 162 | val c = Cipher.getInstance("RSA/ECB/PKCS1Padding") 163 | c.init(Cipher.DECRYPT_MODE, kp?.private) 164 | return c.doFinal(Base64.decode(key, BASE64_FLAGS)) 165 | } catch (ex: Exception) { 166 | val m = Throwable().stackTrace[0] 167 | Log.e(LOG_TAG, "${m}: ${ex}") 168 | return null 169 | } 170 | } 171 | 172 | val prefs = context.getSharedPreferences("appAuthPreference", AppCompatActivity.MODE_PRIVATE) 173 | var key = decryptAesKey(prefs.getString("aesSecretKey", null)) 174 | if (key == null) { 175 | key = ByteArray(16) 176 | SecureRandom.getInstance("SHA1PRNG").nextBytes(key) 177 | prefs.edit().putString("aesSecretKey", encryptAesKey(key)).apply() 178 | val m = Throwable().stackTrace[0] 179 | Log.i(LOG_TAG, "${m}: A new data encryption key was generated.") 180 | } 181 | return SecretKeySpec(key, "AES") 182 | 183 | } -------------------------------------------------------------------------------- /app/src/main/java/net/paonejp/kndzyb/appauthdemo/util/HttpRequestJsonTask.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * AppAuth for Android demonstration application. 3 | * Author: Takashi Yahata (@paoneJP) 4 | * Copyright: (c) 2017 Takashi Yahata 5 | * License: MIT License 6 | */ 7 | 8 | package net.paonejp.kndzyb.appauthdemo.util 9 | 10 | import android.os.AsyncTask 11 | import android.util.Log 12 | import org.json.JSONObject 13 | import java.io.FileNotFoundException 14 | import java.net.HttpURLConnection 15 | import java.net.URL 16 | 17 | 18 | private val NETWORK_TIMEOUT_MSEC = 5000 19 | private val X_HTTP_ERROR = -9 20 | 21 | private val LOG_TAG = "HttpRequestJsonTask" 22 | 23 | 24 | class HttpRequestJsonTask( 25 | private val uri: String, 26 | private val data: String?, 27 | private val accessToken: String?, 28 | private val callback: (Int, JSONObject?, Exception?) -> Unit) 29 | : AsyncTask() { 30 | 31 | class Response(val code: Int, val json: JSONObject?, val ex: Exception?) 32 | 33 | override fun doInBackground(vararg p0: Void?): Response { 34 | 35 | try { 36 | val conn = URL(uri).openConnection() as HttpURLConnection 37 | conn.connectTimeout = NETWORK_TIMEOUT_MSEC 38 | conn.readTimeout = NETWORK_TIMEOUT_MSEC 39 | if (data != null) { 40 | conn.requestMethod = "POST" 41 | conn.outputStream.write(data.toByteArray()) 42 | } 43 | if (accessToken != null) { 44 | conn.addRequestProperty("Authorization", "Bearer ${accessToken}") 45 | } 46 | 47 | var body: String 48 | try { 49 | conn.connect() 50 | body = conn.inputStream.bufferedReader().readText() 51 | } catch (ex: FileNotFoundException) { 52 | body = conn.errorStream.bufferedReader().readText() 53 | } 54 | 55 | if (conn.responseCode != HttpURLConnection.HTTP_OK) { 56 | val m = Throwable().stackTrace[0] 57 | Log.e(LOG_TAG, "${m}: ${conn.responseCode}, ${body}") 58 | } 59 | return Response(conn.responseCode, JSONObject(body), null) 60 | 61 | } catch (ex: Exception) { 62 | val m = Throwable().stackTrace[0] 63 | Log.e(LOG_TAG, "${m}: ${ex}") 64 | return Response(X_HTTP_ERROR, null, ex) 65 | } 66 | } 67 | 68 | override fun onPostExecute(resp: Response) { 69 | callback(resp.code, resp.json, resp.ex) 70 | } 71 | 72 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 12 | 17 | 22 | 27 | 32 | 37 | 42 | 47 | 52 | 57 | 62 | 67 | 72 | 77 | 82 | 87 | 92 | 97 | 102 | 107 | 112 | 113 | 114 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_main.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 21 | 22 |