New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
MBL-1161: Read and store OAuth token from keychain #1963
MBL-1161: Read and store OAuth token from keychain #1963
Conversation
1) The tests in AppEnvironmentTests (the original test file) would all fail if the Keychain is used, because the Keychain requires a hosted app environment to work in unit tests. So any test touching the save/restore functionality has the feature flag turned OFF. 2) The tests in AppEnvironmentTests+OAuthInKeychain are hosted, so the Keychain works. They are a mix of feature flag ON and OFF.
f475880
to
fa861f9
Compare
SwiftFormat found issues:
Generated by 🚫 Danger |
Library/AppEnvironment.swift
Outdated
@@ -295,6 +296,41 @@ public struct AppEnvironment: AppEnvironmentType { | |||
) | |||
} | |||
|
|||
internal static func accountNameForUserId(_ userId: Int) -> String { | |||
return "kickstarter_\(userId)" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is something I went back-and-forth on. The keychain is designed to store passwords for accounts. However, the way our app works right now, we only ever store one OAuth token in the user defaults - we store it for the current user, and remove it when you log out.
So I had two choices here. I could name the keychain account something like kickstarter_current_user
and only have one, or I could name it after the specific user who is logged in like I did here.
I decided to keep it linked to the specific user, but there's an argument to be made that YAGNI and I'm over-complicating things. The big disadvantage is that when you log out, we lose track of the currently logged in user, so it's not easy for us to delete the old keychain item.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't see why we'd need to name the token with the user id. It's not like we allow multiple users to be logged in at the same time, and the token is deleted when the user logs out. I don't think it hurts either, though.
However, how does this naming scheme work across debug/beta/release apps? Does the keychain add namespacing for us or should we be using bundle ids or something instead of just kickstarter
to avoid name collisions between our different apps?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, I'm not sure I see a need any more, either. My original thinking was "hey, this way we can support multiple users in the future!" but I'm starting to think that's not ... particularly useful. You could definitely talk me into making it a single key.
As for the naming scheme, great question. We do use the bundle ID in Keychain.swift, which should identify the oauth token uniquely per app, but I haven't actually tested to make sure that it doesn't overlap between build environments. I think you have to do extra work to enable keychain sharing between app IDs but yeah, let's validate that.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Double-checked and yes, we're already using the bundle ID when we save the key. So there won't be any overlap.
@@ -303,13 +339,21 @@ public struct AppEnvironment: AppEnvironmentType { | |||
let data = userDefaults.dictionary(forKey: self.environmentStorageKey) ?? [:] | |||
|
|||
var service = self.current.apiService | |||
var currentUser: User? | |||
var currentUser: User? // Will only be set if an OAuth token is also set |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Something that is a little confusing now is that you need both the User
and the token to log in, but they're stored in different places 🤯
Library/AppEnvironment.swift
Outdated
|
||
// If there is an oauth token stored, then we can authenticate our api service | ||
|
||
if let oauthToken = fetchOAuthTokenFromKeychain(forUserId: userFromDefaults?.id) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
n.B. the feature flag check is inside this method
Library/AppEnvironment.swift
Outdated
|
||
if let oauthToken = env.apiService.oauthToken?.token { | ||
// Try to save to the keychain, but if that fails, save to user defaults | ||
if !self.storeOAuthTokenToKeychain(oauthToken, forUserId: env.currentUser?.id) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Again, this will fail if the feature flag is off, and it will resume saving it to user defaults.
@testable import Library | ||
import XCTest | ||
|
||
final class AppEnvironmentTests_OAuthInKeychain: XCTestCase { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These are all tests run in the hosted environment, so they can actually test the keychain. I included a bunch of duplicates of tests in AppEnvironmentTests
that just swap the feature flag to 'on' to correctly test that behavior.
/* Awkward bit of condition here - our feature flag values implicitly come from | ||
AppEnvironment.current, which in some cases, is the PREVIOUS environment in the stack. | ||
If you're pushing/saving a new stack, that can be confusing. */ | ||
AppEnvironment.updateRemoteConfigClient(mockConfigClient) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This was an interesting mess I got myself into - what does it mean to test a feature flag from within AppEnvironment
, which is where we store the feature flags?
@@ -81,7 +93,9 @@ final class AppEnvironmentTests: XCTestCase { | |||
AppEnvironment.popEnvironment() | |||
} | |||
|
|||
func testFromStorage_WithNothingStored() { | |||
func testFromStorage_WithNothingStored_featureUseKeychainEnabledIsFalse() { | |||
self.setFeatureUseKeychainEnabled(false) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So now we have tests running in three different states:
AppEnvironmentTests
running in the un-hosted test environment, with the feature flag OFF. This lets us run the original AppEnvironmentTests
without breaking all of them.
AppEnvironmentTests
running in the hosted test environment, with the feature flag OFF. This simulates the app with the feature flag off.
AppEnvironmentTests+OAuthInKeychain
running in the hosted test environment, with the feature flag ON. This simulates the app with the feature flag on.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I have some questions but overall, looks good!
Library/AppEnvironment.swift
Outdated
@@ -295,6 +296,41 @@ public struct AppEnvironment: AppEnvironmentType { | |||
) | |||
} | |||
|
|||
internal static func accountNameForUserId(_ userId: Int) -> String { | |||
return "kickstarter_\(userId)" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't see why we'd need to name the token with the user id. It's not like we allow multiple users to be logged in at the same time, and the token is deleted when the user logs out. I don't think it hurts either, though.
However, how does this naming scheme work across debug/beta/release apps? Does the keychain add namespacing for us or should we be using bundle ids or something instead of just kickstarter
to avoid name collisions between our different apps?
Library/AppEnvironment.swift
Outdated
service = service.login(OauthToken(token: oauthToken)) | ||
|
||
// Move it over to the keychain if we can | ||
_ = self.storeOAuthTokenToKeychain(oauthToken, forUserId: userFromDefaults?.id) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should we remove the token from user defaults if storing it to the keychain is successful? Alternatively, do we need to save the token here at all, since we save it in saveEnvironment
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmmm very good point about just keeping it in saveEnvironment
! Let me check, that would be cleaner.
|
||
let next = Environment() | ||
self.pushEnvironment(next) | ||
self.replaceCurrentEnvironment(next) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This fixes a crash in resetStackForUnitTests
because it will never be truly empty. If it's empty, calls to isFeatureEnabled
will fail.
Regarding testing on device: I'm going to deploy the beta and then run the tests that way. I won't mark the ticket as complete until I'm happy with on-device testing. |
📲 What
Store the OAuth token in the keychain, instead of in user defaults. Additional code moves the old token out of defaults and into the keychain if you were already logged in.
🤔 Why
Leaving the token in user defaults is insecure.
🛠 How
We store the OAuth token per-user, using a pseudo-account-name like
kickstarter_12345
for user with id12345
. The change is gated behind a feature flag, and hopefully fairly robust to any keychain failures or exceptions.⏰ TODO