Skip to content

Commit

Permalink
Support shareable credentials across sites from same company
Browse files Browse the repository at this point in the history
  • Loading branch information
CDRussell committed Aug 2, 2023
1 parent cc39833 commit e508c68
Show file tree
Hide file tree
Showing 36 changed files with 2,092 additions and 152 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ class FileBasedJavascriptInjector @Inject constructor() : JavascriptInjector {
return signOutFunctions
}

fun loadJs(resourceName: String): String = readResource(resourceName).use { it?.readText() }.orEmpty()
private fun loadJs(resourceName: String): String = readResource(resourceName).use { it?.readText() }.orEmpty()

private fun readResource(resourceName: String): BufferedReader? {
return javaClass.classLoader?.getResource(resourceName)?.openStream()?.bufferedReader()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import com.duckduckgo.autofill.impl.jsbridge.request.SupportedAutofillTriggerTyp
import com.duckduckgo.autofill.impl.jsbridge.request.SupportedAutofillTriggerType.AUTOPROMPT
import com.duckduckgo.autofill.impl.jsbridge.request.SupportedAutofillTriggerType.USER_INITIATED
import com.duckduckgo.autofill.impl.jsbridge.response.AutofillResponseWriter
import com.duckduckgo.autofill.impl.sharedcreds.ShareableCredentials
import com.duckduckgo.autofill.impl.ui.credential.passwordgeneration.Actions
import com.duckduckgo.autofill.impl.ui.credential.passwordgeneration.Actions.DeleteAutoLogin
import com.duckduckgo.autofill.impl.ui.credential.passwordgeneration.Actions.DiscardAutoLoginId
Expand Down Expand Up @@ -82,6 +83,7 @@ interface AutofillJavascriptInterface {
class AutofillStoredBackJavascriptInterface @Inject constructor(
private val requestParser: AutofillRequestParser,
private val autofillStore: AutofillStore,
private val shareableCredentials: ShareableCredentials,
private val autofillMessagePoster: AutofillMessagePoster,
private val autofillResponseWriter: AutofillResponseWriter,
@AppCoroutineScope private val coroutineScope: CoroutineScope,
Expand Down Expand Up @@ -146,8 +148,14 @@ class AutofillStoredBackJavascriptInterface @Inject constructor(
request: AutofillDataRequest,
triggerType: LoginTriggerType,
) {
val allCredentials = autofillStore.getCredentials(url)
val credentials = filterRequestedSubtypes(request, allCredentials)
val matches = mutableListOf<LoginCredentials>()
val directMatches = autofillStore.getCredentials(url)
val shareableMatches = shareableCredentials.shareableCredentials(url)
Timber.v("Direct matches: %d, shareable matches: %d for %s", directMatches.size, shareableMatches.size, url)
matches.addAll(directMatches)
matches.addAll(shareableMatches)

val credentials = filterRequestedSubtypes(request, matches)

if (credentials.isEmpty()) {
callback?.noCredentialsAvailable(url)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,10 @@ package com.duckduckgo.autofill.impl.configuration

import com.duckduckgo.app.email.EmailManager
import com.duckduckgo.autofill.api.AutofillCapabilityChecker
import com.duckduckgo.autofill.api.domain.app.LoginCredentials
import com.duckduckgo.autofill.api.store.AutofillStore
import com.duckduckgo.autofill.impl.jsbridge.response.AvailableInputTypeCredentials
import com.duckduckgo.autofill.impl.sharedcreds.ShareableCredentials
import com.duckduckgo.di.scopes.AppScope
import com.squareup.anvil.annotations.ContributesBinding
import javax.inject.Inject
Expand All @@ -38,6 +40,7 @@ class RealAutofillRuntimeConfigProvider @Inject constructor(
private val autofillStore: AutofillStore,
private val runtimeConfigurationWriter: RuntimeConfigurationWriter,
private val autofillCapabilityChecker: AutofillCapabilityChecker,
private val shareableCredentials: ShareableCredentials,
) : AutofillRuntimeConfigProvider {
override suspend fun getRuntimeConfiguration(
rawJs: String,
Expand Down Expand Up @@ -76,10 +79,14 @@ class RealAutofillRuntimeConfigProvider @Inject constructor(
return if (url == null || !autofillCapabilityChecker.canInjectCredentialsToWebView(url)) {
AvailableInputTypeCredentials(username = false, password = false)
} else {
val savedCredentials = autofillStore.getCredentials(url)
val matches = mutableListOf<LoginCredentials>()
val directMatches = autofillStore.getCredentials(url)
val shareableMatches = shareableCredentials.shareableCredentials(url)
matches.addAll(directMatches)
matches.addAll(shareableMatches)

val usernameSearch = savedCredentials.find { !it.username.isNullOrEmpty() }
val passwordSearch = savedCredentials.find { !it.password.isNullOrEmpty() }
val usernameSearch = matches.find { !it.username.isNullOrEmpty() }
val passwordSearch = matches.find { !it.password.isNullOrEmpty() }

AvailableInputTypeCredentials(username = usernameSearch != null, password = passwordSearch != null)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import com.duckduckgo.app.global.DispatcherProvider
import com.duckduckgo.autofill.api.InternalTestUserChecker
import com.duckduckgo.autofill.api.encoding.UrlUnicodeNormalizer
import com.duckduckgo.autofill.api.urlmatcher.AutofillUrlMatcher
import com.duckduckgo.autofill.impl.urlmatcher.AutofillDomainNameUrlMatcher
import com.duckduckgo.autofill.store.ALL_MIGRATIONS
import com.duckduckgo.autofill.store.AutofillDatabase
import com.duckduckgo.autofill.store.AutofillPrefsStore
Expand All @@ -33,7 +34,6 @@ import com.duckduckgo.autofill.store.RealInternalTestUserStore
import com.duckduckgo.autofill.store.RealLastUpdatedTimeProvider
import com.duckduckgo.autofill.store.feature.AutofillFeatureRepository
import com.duckduckgo.autofill.store.feature.RealAutofillFeatureRepository
import com.duckduckgo.autofill.store.urlmatcher.AutofillDomainNameUrlMatcher
import com.duckduckgo.di.scopes.AppScope
import com.squareup.anvil.annotations.ContributesTo
import dagger.Module
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/*
* Copyright (c) 2023 DuckDuckGo
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.duckduckgo.autofill.impl.sharedcreds

import com.duckduckgo.app.global.DispatcherProvider
import com.duckduckgo.di.scopes.AppScope
import com.squareup.anvil.annotations.ContributesBinding
import com.squareup.moshi.Moshi
import dagger.SingleInstanceIn
import javax.inject.Inject
import kotlinx.coroutines.withContext

interface SharedCredentialJsonReader {
suspend fun read(): String?
}

@SingleInstanceIn(AppScope::class)
@ContributesBinding(AppScope::class)
class AppleSharedCredentialsJsonReader @Inject constructor(
private val moshi: Moshi,
private val dispatchers: DispatcherProvider,
) : SharedCredentialJsonReader {

override suspend fun read(): String? {
return withContext(dispatchers.io()) {
loadJson()
}
}

private fun loadJson(): String? {
val reader = javaClass.classLoader?.getResource(JSON_FILENAME)?.openStream()?.bufferedReader()
return reader.use { it?.readText() }
}

private companion object {
private const val JSON_FILENAME = "shared-credentials.json"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/*
* Copyright (c) 2023 DuckDuckGo
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.duckduckgo.autofill.impl.sharedcreds

import com.duckduckgo.app.global.DispatcherProvider
import com.duckduckgo.autofill.api.urlmatcher.AutofillUrlMatcher
import com.duckduckgo.autofill.api.urlmatcher.AutofillUrlMatcher.ExtractedUrlParts
import com.duckduckgo.autofill.impl.sharedcreds.SharedCredentialsParser.OmnidirectionalRule
import com.duckduckgo.autofill.impl.sharedcreds.SharedCredentialsParser.SharedCredentialConfig
import com.duckduckgo.autofill.impl.sharedcreds.SharedCredentialsParser.UnidirectionalRule
import com.duckduckgo.di.scopes.AppScope
import com.squareup.anvil.annotations.ContributesBinding
import com.squareup.moshi.Moshi
import com.squareup.moshi.Types
import dagger.SingleInstanceIn
import java.io.IOException
import javax.inject.Inject
import kotlinx.coroutines.withContext
import timber.log.Timber

interface SharedCredentialsParser {
suspend fun read(): SharedCredentialConfig

data class SharedCredentialConfig(
val omnidirectionalRules: List<OmnidirectionalRule>,
val unidirectionalRules: List<UnidirectionalRule>,
) {
override fun toString(): String {
return "SharedCredentialConfig(omnidirectionalRules=${omnidirectionalRules.size}, unidirectionalRules=${unidirectionalRules.size})"
}
}

data class OmnidirectionalRule(val shared: List<ExtractedUrlParts>)
data class UnidirectionalRule(
val from: List<ExtractedUrlParts>,
val to: List<ExtractedUrlParts>,
val fromDomainsAreObsoleted: Boolean?,
)
}

@SingleInstanceIn(AppScope::class)
@ContributesBinding(AppScope::class)
class AppleSharedCredentialsParser @Inject constructor(
private val jsonReader: SharedCredentialJsonReader,
private val moshi: Moshi,
private val dispatchers: DispatcherProvider,
private val autofillUrlMatcher: AutofillUrlMatcher,
) : SharedCredentialsParser {

override suspend fun read(): SharedCredentialConfig {
return withContext(dispatchers.io()) {
val json = jsonReader.read()
convertJsonToRules(json)
}
}

private fun convertJsonToRules(json: String?): SharedCredentialConfig {
if (json == null) return SharedCredentialConfig(emptyList(), emptyList())

val adapter = moshi.adapter<List<Rule>>(Types.newParameterizedType(List::class.java, Rule::class.java))

val rules = try {
adapter.fromJson(json) ?: return CONFIG_WHEN_ERROR_HAPPENED
} catch (e: IOException) {
Timber.e(e, "Failed to load Apple shared credential config")
return CONFIG_WHEN_ERROR_HAPPENED
}

val omnidirectionalRules = mutableListOf<OmnidirectionalRule>()
val unidirectionalRules = mutableListOf<UnidirectionalRule>()

rules.forEach { rule ->
if (rule.shared != null) {
processOmnidirectionalRule(rule.shared, omnidirectionalRules)
} else if (rule.from != null && rule.to != null) {
processUnidirectionalRule(rule.from, rule.to, unidirectionalRules, rule)
} else {
// not a rule we understand
Timber.w("Could not process rule as it appears to be invalid: %s", rule)
}
}

return SharedCredentialConfig(
omnidirectionalRules = omnidirectionalRules,
unidirectionalRules = unidirectionalRules,
)
}

private fun processUnidirectionalRule(
from: List<String>,
to: List<String>,
unidirectionalRules: MutableList<UnidirectionalRule>,
rule: Rule,
) {
val fromList = from.map { autofillUrlMatcher.extractUrlPartsForAutofill(it) }
val toList = to.map { autofillUrlMatcher.extractUrlPartsForAutofill(it) }
unidirectionalRules.add(UnidirectionalRule(fromList, toList, rule.fromDomainsAreObsoleted))
}

private fun processOmnidirectionalRule(
shared: List<String>,
omnidirectionalRules: MutableList<OmnidirectionalRule>,
) {
val sharedList = shared.map { autofillUrlMatcher.extractUrlPartsForAutofill(it) }
omnidirectionalRules.add(OmnidirectionalRule(sharedList))
}

data class Rule(
val shared: List<String>?,
val from: List<String>?,
val to: List<String>?,
val fromDomainsAreObsoleted: Boolean?,
)

private companion object {
private val CONFIG_WHEN_ERROR_HAPPENED = SharedCredentialConfig(emptyList(), emptyList())
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/*
* Copyright (c) 2023 DuckDuckGo
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.duckduckgo.autofill.impl.sharedcreds

import com.duckduckgo.app.di.AppCoroutineScope
import com.duckduckgo.app.global.DispatcherProvider
import com.duckduckgo.autofill.api.domain.app.LoginCredentials
import com.duckduckgo.autofill.api.store.AutofillStore
import com.duckduckgo.autofill.api.urlmatcher.AutofillUrlMatcher
import com.duckduckgo.autofill.api.urlmatcher.AutofillUrlMatcher.ExtractedUrlParts
import com.duckduckgo.autofill.impl.sharedcreds.SharedCredentialsParser.SharedCredentialConfig
import com.duckduckgo.di.scopes.AppScope
import com.squareup.anvil.annotations.ContributesBinding
import dagger.SingleInstanceIn
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart.LAZY
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.async
import kotlinx.coroutines.withContext

interface ShareableCredentials {
suspend fun shareableCredentials(sourceUrl: String): List<LoginCredentials>
}

@SingleInstanceIn(AppScope::class)
@ContributesBinding(AppScope::class)
class AppleShareableCredentials @Inject constructor(
private val jsonParser: SharedCredentialsParser,
private val dispatchers: DispatcherProvider,
private val shareableCredentialsUrlGenerator: ShareableCredentialsUrlGenerator,
private val autofillStore: AutofillStore,
private val autofillUrlMatcher: AutofillUrlMatcher,
@AppCoroutineScope private val appCoroutineScope: CoroutineScope,
) : ShareableCredentials {

private val sharedCredentialConfig: Deferred<SharedCredentialConfig> = appCoroutineScope.async(start = LAZY) {
jsonParser.read()
}

override suspend fun shareableCredentials(sourceUrl: String): List<LoginCredentials> {
return withContext(dispatchers.io()) {
val config = sharedCredentialConfig.await()
val shareableUrls = shareableCredentialsUrlGenerator.generateShareableUrls(sourceUrl, config)
return@withContext matchingCredentials(shareableUrls)
}
}

private suspend fun matchingCredentials(
shareableUrls: List<ExtractedUrlParts>,
): List<LoginCredentials> {
val logins = mutableListOf<LoginCredentials>()
shareableUrls.forEach { shareableUrlParts ->
val eTldPlusOne = shareableUrlParts.eTldPlus1
logins.addAll(
if (eTldPlusOne != null) {
autofillStore.getCredentials(eTldPlusOne)
} else {
emptyList()
},
)
}

return logins.distinct()
}
}
Loading

0 comments on commit e508c68

Please sign in to comment.