From cd3731b1da4ee6573eaa33b3c1ba8f69049b8018 Mon Sep 17 00:00:00 2001 From: Juho Kilpikoski Date: Thu, 29 Dec 2022 15:09:43 +0200 Subject: [PATCH] Support project based login (#13) --- .../com/speechly/client/identity/AuthToken.kt | 10 ++- .../client/identity/IdentityClient.kt | 8 +-- .../client/identity/IdentityService.kt | 66 ++++++++++++++++++- .../com/speechly/client/slu/SluClient.kt | 8 ++- .../com/speechly/client/slu/SluStream.kt | 34 ++++++---- .../com/speechly/client/speech/Client.kt | 57 +++++++++++----- .../client/identity/IdentityServiceTest.kt | 18 +++-- .../com/speechly/clienttest/MainActivity.kt | 5 +- 8 files changed, 157 insertions(+), 49 deletions(-) diff --git a/android-client/src/main/kotlin/com/speechly/client/identity/AuthToken.kt b/android-client/src/main/kotlin/com/speechly/client/identity/AuthToken.kt index bff8626..45ce5f1 100644 --- a/android-client/src/main/kotlin/com/speechly/client/identity/AuthToken.kt +++ b/android-client/src/main/kotlin/com/speechly/client/identity/AuthToken.kt @@ -19,13 +19,15 @@ class InvalidJWTException(message: String) : Throwable(message) * The token is usually obtained from Speechly Identity Service and can be cached for its expiration period. * * @param appId the ID of Speechly application that will be accessed with this token. + * @param projectId the ID of Speechly project that will be accessed with this token. * @param deviceId Speechly device identifier that is authorised to use this token. * @param expiresAt the timestamp of token expiration. * @param authScopes Speechly APIs that can be accessed with this token. * @param tokenString the token value that should be passed to API when accessing it. */ data class AuthToken( - val appId: UUID, + val appId: UUID?, + val projectId: UUID?, val deviceId: UUID, val expiresAt: Instant, val authScopes: Set, @@ -69,7 +71,8 @@ data class AuthToken( return try { AuthToken( - appId = UUID.fromString(payload.appId), + appId = payload.appId?.let(UUID::fromString), + projectId = payload.projectId?.let(UUID::fromString), deviceId = UUID.fromString(payload.deviceId), expiresAt = Instant.ofEpochSecond(payload.expiresAt), authScopes = scopes, @@ -104,7 +107,8 @@ data class AuthToken( } private data class TokenPayload( - val appId: String, + val appId: String? = null, + val projectId: String? = null, val deviceId: String, @Json(name = "scope") diff --git a/android-client/src/main/kotlin/com/speechly/client/identity/IdentityClient.kt b/android-client/src/main/kotlin/com/speechly/client/identity/IdentityClient.kt index c0debe2..c095c39 100644 --- a/android-client/src/main/kotlin/com/speechly/client/identity/IdentityClient.kt +++ b/android-client/src/main/kotlin/com/speechly/client/identity/IdentityClient.kt @@ -1,9 +1,9 @@ package com.speechly.client.identity -import com.speechly.api.identity.v1.IdentityGrpcKt -import com.speechly.api.identity.v1.LoginRequest -import com.speechly.api.identity.v1.LoginResponse +import com.speechly.identity.v2.LoginRequest +import com.speechly.identity.v2.LoginResponse import com.speechly.client.grpc.buildChannel +import com.speechly.identity.v2.IdentityAPIGrpcKt import io.grpc.ManagedChannel import io.grpc.Status import java.io.Closeable @@ -42,7 +42,7 @@ class GrpcIdentityClient( private val channel: ManagedChannel, private val shutdownTimeout: Long = 5 ) : IdentityClient { - private val clientStub = IdentityGrpcKt.IdentityCoroutineStub(this.channel) + private val clientStub = IdentityAPIGrpcKt.IdentityAPICoroutineStub(this.channel) companion object { /** diff --git a/android-client/src/main/kotlin/com/speechly/client/identity/IdentityService.kt b/android-client/src/main/kotlin/com/speechly/client/identity/IdentityService.kt index 460b28d..f786466 100644 --- a/android-client/src/main/kotlin/com/speechly/client/identity/IdentityService.kt +++ b/android-client/src/main/kotlin/com/speechly/client/identity/IdentityService.kt @@ -1,7 +1,9 @@ package com.speechly.client.identity -import com.speechly.api.identity.v1.LoginRequest +import com.speechly.identity.v2.LoginRequest import com.speechly.client.cache.CacheService +import com.speechly.identity.v2.ApplicationScope +import com.speechly.identity.v2.ProjectScope import java.io.Closeable import java.time.Instant import java.util.UUID @@ -17,6 +19,14 @@ interface IdentityService : Closeable { * @param deviceId Speechly device ID to use for authentication. */ suspend fun authenticate(appId: UUID, deviceId: UUID): AuthToken + + /** + * Fetches a new project level authentication token and decodes it into `AuthToken`. + * + * @param projectId Speechly project ID to use for authentication. + * @param deviceId Speechly device ID to use for authentication. + */ + suspend fun authenticateProject(projectId: UUID, deviceId: UUID): AuthToken } /** @@ -46,9 +56,27 @@ class BasicIdentityService( } override suspend fun authenticate(appId: UUID, deviceId: UUID): AuthToken { + val scope = ApplicationScope.newBuilder() + .setAppId(appId.toString()) + val request = LoginRequest .newBuilder() - .setAppId(appId.toString()) + .setApplication(scope) + .setDeviceId(deviceId.toString()) + .build() + + val response = client.login(request) + + return AuthToken.fromJWT(response.token) + } + + override suspend fun authenticateProject(projectId: UUID, deviceId: UUID): AuthToken { + val scope = ProjectScope.newBuilder() + .setProjectId(projectId.toString()) + + val request = LoginRequest + .newBuilder() + .setProject(scope) .setDeviceId(deviceId.toString()) .build() @@ -107,6 +135,20 @@ class CachingIdentityService( return this.reloadToken(appId, deviceId) } + override suspend fun authenticateProject(projectId: UUID, deviceId: UUID): AuthToken { + // Try to load the token from the cache. + val token = this.loadProjectToken(projectId, deviceId) + + // Make sure that cached token exists and won't expire too soon. + val expirationTime = Instant.now().plusSeconds(60) + if (token != null && token.validateExpiry(expirationTime)) { + return token + } + + // Otherwise reload the token from the API and update the cached value. + return this.reloadProjectToken(projectId, deviceId) + } + override fun close() { this.baseService.close() } @@ -121,10 +163,28 @@ class CachingIdentityService( } } + private fun loadProjectToken(projectId: UUID, deviceId: UUID): AuthToken? { + val cacheValue = this.cacheService?.loadString(this.makeCacheKey(projectId, deviceId)) ?: return null + + return try { + AuthToken.fromJWT(cacheValue) + } catch (_: Throwable) { + null + } + } + private suspend fun reloadToken(appId: UUID, deviceId: UUID): AuthToken { val token = this.baseService.authenticate(appId, deviceId) - this.cacheService?.storeString(this.makeCacheKey(token.appId, token.deviceId), token.tokenString) + this.cacheService?.storeString(this.makeCacheKey(token.appId!!, token.deviceId), token.tokenString) + + return token + } + + private suspend fun reloadProjectToken(projectId: UUID, deviceId: UUID): AuthToken { + val token = this.baseService.authenticateProject(projectId, deviceId) + + this.cacheService?.storeString(this.makeCacheKey(token.projectId!!, token.deviceId), token.tokenString) return token } diff --git a/android-client/src/main/kotlin/com/speechly/client/slu/SluClient.kt b/android-client/src/main/kotlin/com/speechly/client/slu/SluClient.kt index eb6fe2c..7247643 100644 --- a/android-client/src/main/kotlin/com/speechly/client/slu/SluClient.kt +++ b/android-client/src/main/kotlin/com/speechly/client/slu/SluClient.kt @@ -9,6 +9,7 @@ import io.grpc.stub.MetadataUtils import java.io.Closeable import java.util.concurrent.TimeUnit import kotlinx.coroutines.flow.Flow +import java.util.UUID import kotlinx.coroutines.ExperimentalCoroutinesApi as ExperimentalCoroutinesApi /** @@ -22,7 +23,7 @@ interface SluClient : Closeable { * @param streamConfig the configuration of the SLU stream. */ @ExperimentalCoroutinesApi - fun stream(authToken: AuthToken, streamConfig: StreamConfig, audioFlow: Flow): SluStream + fun stream(authToken: AuthToken, streamConfig: StreamConfig, audioFlow: Flow, contextAppId: UUID?): SluStream } /** @@ -52,7 +53,7 @@ class GrpcSluClient( } @ExperimentalCoroutinesApi - override fun stream(authToken: AuthToken, streamConfig: StreamConfig, audioFlow: Flow): GrpcSluStream { + override fun stream(authToken: AuthToken, streamConfig: StreamConfig, audioFlow: Flow, contextAppId: UUID?): GrpcSluStream { val metadata = Metadata() metadata.put( Metadata.Key.of("Authorization", Metadata.ASCII_STRING_MARSHALLER), @@ -62,7 +63,8 @@ class GrpcSluClient( return GrpcSluStream( MetadataUtils.attachHeaders(this.clientStub, metadata), streamConfig, - audioFlow + audioFlow, + contextAppId ) } diff --git a/android-client/src/main/kotlin/com/speechly/client/slu/SluStream.kt b/android-client/src/main/kotlin/com/speechly/client/slu/SluStream.kt index 44fc637..458d4e7 100644 --- a/android-client/src/main/kotlin/com/speechly/client/slu/SluStream.kt +++ b/android-client/src/main/kotlin/com/speechly/client/slu/SluStream.kt @@ -5,15 +5,26 @@ import com.speechly.api.slu.v1.* import kotlinx.coroutines.* import kotlinx.coroutines.flow.* import java.io.Closeable +import java.util.UUID -private val startReq: SLURequest = SLURequest - .newBuilder() - .setEvent( - SLUEvent - .newBuilder() - .setEvent(SLUEvent.Event.START) - .build() - ).build() +private fun startReq(appId: UUID?): SLURequest { + val event = if (appId == null) { + SLUEvent + .newBuilder() + .setEvent(SLUEvent.Event.START) + .build() + } else { + SLUEvent + .newBuilder() + .setEvent(SLUEvent.Event.START) + .setAppId(appId.toString()) + .build() + } + return SLURequest + .newBuilder() + .setEvent(event) + .build() +} private val stopReq: SLURequest = SLURequest .newBuilder() @@ -65,7 +76,7 @@ data class StreamConfig( /** * This class represents an exception thrown when trying to interact with a closed stream. */ -class StreamClosedException: Throwable("SLU stream is closed") +class StreamClosedException : Throwable("SLU stream is closed") /** * A SLU stream implementation backed by Speechly gRPC SLU API. @@ -79,7 +90,8 @@ class StreamClosedException: Throwable("SLU stream is closed") class GrpcSluStream( clientStub: SLUGrpcKt.SLUCoroutineStub, streamConfig: StreamConfig, - audioFlow: Flow + audioFlow: Flow, + streamAppId: UUID? // private val responseChannel: Channel = Channel() ) : SluStream { var responseFlow: Flow? = null @@ -103,7 +115,7 @@ class GrpcSluStream( } }.onStart { emit(configReq) - emit(startReq) + emit(startReq(streamAppId)) }.onCompletion { emit(stopReq) } diff --git a/android-client/src/main/kotlin/com/speechly/client/speech/Client.kt b/android-client/src/main/kotlin/com/speechly/client/speech/Client.kt index 3681a12..c23df7e 100644 --- a/android-client/src/main/kotlin/com/speechly/client/speech/Client.kt +++ b/android-client/src/main/kotlin/com/speechly/client/speech/Client.kt @@ -3,15 +3,11 @@ package com.speechly.client.speech import android.app.Activity import android.content.Context import android.media.AudioManager -import androidx.appcompat.app.AppCompatActivity -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleObserver -import androidx.lifecycle.OnLifecycleEvent -import com.speechly.api.slu.v1.SLURequest import com.speechly.api.slu.v1.SLUResponse import com.speechly.client.cache.SharedPreferencesCache import com.speechly.client.device.CachingIdProvider import com.speechly.client.device.DeviceIdProvider +import com.speechly.client.identity.AuthToken import com.speechly.client.identity.CachingIdentityService import com.speechly.client.identity.IdentityService import com.speechly.client.slu.* @@ -19,8 +15,6 @@ import kotlinx.coroutines.* import java.io.Closeable import java.util.UUID import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.flow.flowOf /** * A client for Speechly Spoken Language Understanding (SLU) API. @@ -32,7 +26,7 @@ interface ApiClient : Closeable { */ @DelicateCoroutinesApi @ExperimentalCoroutinesApi - fun startContext() + fun startContext(contextAppId: UUID? = null) /** * Stops current SLU context by sending a stop context event to the API and muting the microphone * delayed by contextStopDelay = 250 ms @@ -80,21 +74,39 @@ interface ApiClient : Closeable { */ class NoActiveStreamException : Throwable("No active SLU stream available") +enum class AuthScope { + UNDEFINED, APPLICATION, PROJECT +} + class Client ( - private val appId: UUID, + private val appId: UUID?, + private val projectId: UUID?, language: StreamConfig.LanguageCode, deviceIdProvider: DeviceIdProvider, private val identityService: IdentityService, private val sluClient: GrpcSluClient, private val audioRecorder: AudioRecorder, + ) : ApiClient { private val streams: MutableList = mutableListOf() private val deviceId: UUID = deviceIdProvider.getDeviceId() private val coroutineScope = CoroutineScope(Job()) + private var authScope: AuthScope = AuthScope.UNDEFINED + init { - GlobalScope.launch(Dispatchers.IO) { - identityService.authenticate(appId, deviceId) // fetch token + if (appId != null) { + authScope = AuthScope.APPLICATION + } else if (projectId != null) { + authScope = AuthScope.PROJECT + } + + coroutineScope.launch(Dispatchers.IO) { + if (appId != null) { + identityService.authenticate(appId, deviceId) // fetch token + } else if (projectId != null) { + identityService.authenticateProject(projectId, deviceId) // fetch token + } } } @@ -108,7 +120,8 @@ class Client ( companion object { fun fromActivity( activity: Activity, - appId: UUID, + appId: UUID?, + projectId: UUID? = null, language: StreamConfig.LanguageCode = StreamConfig.LanguageCode.EN_US, target: String = "api.speechly.com", secure: Boolean = true @@ -131,6 +144,7 @@ class Client ( return Client( appId, + projectId, language, cachingIdProvider, cachingIdentityService, @@ -184,12 +198,23 @@ class Client ( @DelicateCoroutinesApi @ExperimentalCoroutinesApi - override fun startContext() { + override fun startContext(contextAppId: UUID?) { coroutineScope.launch(Dispatchers.IO) { - val token = identityService.authenticate(appId, deviceId) + val token: AuthToken = when (authScope) { + AuthScope.APPLICATION -> { + identityService.authenticate(appId!!, deviceId) + } + AuthScope.PROJECT -> { + identityService.authenticateProject(projectId!!, deviceId) + } + else -> { + throw IllegalStateException("Unknown auth scope") + } + } + try { val audioFlow: Flow = audioRecorder.startRecording() - val stream = sluClient.stream(token, streamConfig, audioFlow) + val stream = sluClient.stream(token, streamConfig, audioFlow, contextAppId) streams.add(stream) var segment: Segment? = null @@ -279,13 +304,13 @@ class Client ( it.close() this.streams.remove(it) } - this.coroutineScope.cancel() } override fun close() { this.audioRecorder.close() this.sluClient.close() this.identityService.close() + this.coroutineScope.cancel() } private fun getReadStream(): SluStream? { diff --git a/android-client/src/test/kotlin/com/speechly/client/identity/IdentityServiceTest.kt b/android-client/src/test/kotlin/com/speechly/client/identity/IdentityServiceTest.kt index 81536d2..b25f35a 100644 --- a/android-client/src/test/kotlin/com/speechly/client/identity/IdentityServiceTest.kt +++ b/android-client/src/test/kotlin/com/speechly/client/identity/IdentityServiceTest.kt @@ -1,6 +1,7 @@ package com.speechly.client.identity -import com.speechly.api.identity.v1.IdentityOuterClass +import com.speechly.identity.v2.LoginRequest +import com.speechly.identity.v2.LoginResponse import kotlinx.coroutines.runBlocking import java.util.stream.Stream import org.junit.jupiter.params.ParameterizedTest @@ -21,7 +22,7 @@ internal class IdentityServiceTest { wantException: Boolean ) = runBlocking { val id = UUID.randomUUID() - val response = IdentityOuterClass.LoginResponse.newBuilder().setToken(token).build() + val response = LoginResponse.newBuilder().setToken(token).build() val client = MockIdentityClient(response, exception) val service = BasicIdentityService(client) @@ -46,7 +47,8 @@ internal class IdentityServiceTest { deviceId = UUID.fromString("22222222-2222-2222-2222-222222222222"), expiresAt = Instant.ofEpochSecond(1909239022), authScopes = setOf(AuthToken.AuthScope.WLU, AuthToken.AuthScope.SLU), - tokenString ="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhcHBJZCI6IjExMTExMTExLTExMTEtMTExMS0xMTExLTExMTExMTExMTExMSIsImRldmljZUlkIjoiMjIyMjIyMjItMjIyMi0yMjIyLTIyMjItMjIyMjIyMjIyMjIyIiwiY29uZmlnSWQiOiIzMzMzMzMzMy0zMzMzLTMzMzMtMzMzMy0zMzMzMzMzMzMzMzMiLCJsYW5ndWFnZUNvZGUiOiJlbi1VUyIsInNjb3BlIjoic2x1IHdsdSIsImlzcyI6Imh0dHBzOi8vYXBpLnNwZWVjaGx5LmNvbSIsImF1ZCI6Imh0dHBzOi8vYXBpLnNwZWVjaGx5LmNvbSIsImlhdCI6MTU5OTIzOTAyMiwiZXhwIjoxOTA5MjM5MDIyfQ.zBvA4ahMj5LAzDac61rvw0KwW35X7XkTIiY8AvYf_4I" + tokenString ="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhcHBJZCI6IjExMTExMTExLTExMTEtMTExMS0xMTExLTExMTExMTExMTExMSIsImRldmljZUlkIjoiMjIyMjIyMjItMjIyMi0yMjIyLTIyMjItMjIyMjIyMjIyMjIyIiwiY29uZmlnSWQiOiIzMzMzMzMzMy0zMzMzLTMzMzMtMzMzMy0zMzMzMzMzMzMzMzMiLCJsYW5ndWFnZUNvZGUiOiJlbi1VUyIsInNjb3BlIjoic2x1IHdsdSIsImlzcyI6Imh0dHBzOi8vYXBpLnNwZWVjaGx5LmNvbSIsImF1ZCI6Imh0dHBzOi8vYXBpLnNwZWVjaGx5LmNvbSIsImlhdCI6MTU5OTIzOTAyMiwiZXhwIjoxOTA5MjM5MDIyfQ.zBvA4ahMj5LAzDac61rvw0KwW35X7XkTIiY8AvYf_4I", + projectId = null ), false ), @@ -58,7 +60,8 @@ internal class IdentityServiceTest { deviceId = UUID.randomUUID(), expiresAt = Instant.MAX, authScopes = emptySet(), - tokenString ="" + tokenString ="", + projectId = null ), true ), @@ -70,7 +73,8 @@ internal class IdentityServiceTest { deviceId = UUID.randomUUID(), expiresAt = Instant.MAX, authScopes = emptySet(), - tokenString = "" + tokenString = "", + projectId = null ), true ) @@ -79,10 +83,10 @@ internal class IdentityServiceTest { } private class MockIdentityClient( - private val response: IdentityOuterClass.LoginResponse, + private val response: LoginResponse, private val exception: Throwable? ) : IdentityClient { - override suspend fun login(request: IdentityOuterClass.LoginRequest): IdentityOuterClass.LoginResponse { + override suspend fun login(request: LoginRequest): LoginResponse { if (this.exception != null) { throw this.exception } diff --git a/client-test/src/main/kotlin/com/speechly/clienttest/MainActivity.kt b/client-test/src/main/kotlin/com/speechly/clienttest/MainActivity.kt index d39ff3f..315d6f2 100644 --- a/client-test/src/main/kotlin/com/speechly/clienttest/MainActivity.kt +++ b/client-test/src/main/kotlin/com/speechly/clienttest/MainActivity.kt @@ -47,7 +47,7 @@ class MainActivity : AppCompatActivity() { MotionEvent.ACTION_DOWN -> { textView?.visibility = View.VISIBLE textView?.text = "" - speechlyClient?.startContext() + speechlyClient?.startContext(UUID.fromString("312137a2-1c8b-47f8-a553-b88b1a884e08")) } MotionEvent.ACTION_UP -> { speechlyClient!!.stopContext() @@ -67,7 +67,8 @@ class MainActivity : AppCompatActivity() { setContentView(R.layout.activity_main) speechlyClient = Client.fromActivity( activity = this, - appId = UUID.fromString("312137a2-1c8b-47f8-a553-b88b1a884e08") + appId = null, + projectId = UUID.fromString("8030982e-8a1b-491c-bec7-8838de549ee9") ) this.button = findViewById(R.id.speechly) this.recyclerView = findViewById(R.id.recycler_view)