My iOS app checks the iCloud account status and then requests an iCloud WebToken using the following method:
#objc static func fetchWebAuthToken ( _ apiToken : String, _ callback : #escaping CCallbackFunctionWithBoolAndString )
{
let fetchAuthorization = CKFetchWebAuthTokenOperation(apiToken: apiToken)
fetchAuthorization.fetchWebAuthTokenCompletionBlock = { webToken, error in
guard let webToken = webToken, error == nil else {
callback ( false, "[SWIFT] fetchWebAuthToken() error. " + (error?.localizedDescription ?? ""));
return;
}
let encodedWebToken = token.addingPercentEncoding (
withAllowedCharacters: CharacterSet(charactersIn: "+/=").inverted
) ?? token
callback ( true, encodedWebToken );
return;
}
CKContainer.default().privateCloudDatabase.add(fetchAuthorization);
}
Everything works correctly and a properly formatted web token is returned.
I then take that web token and, using Postman, I form the request (with exact values removed):
https://api.apple-cloudkit.com/database/1/iCloud.com.[my container]/development/private/users/caller?ckAPIToken=[development container token]&ckWebAuthToken=[web token]
The response is:
{
"uuid": "[abc]",
"serverErrorCode": "ACCESS_DENIED",
"reason": "private db access disabled for this account"
}
If I request to the public database instead, I get a valid and correct response:
https://api.apple-cloudkit.com/database/1/iCloud.com.[my container]/development/public/users/caller?ckAPIToken=[development container token]&ckWebAuthToken=[web token]
{
"userRecordName": "_[user id]",
"nameComponents": {
"givenName": "[First Name]",
"familyName": "[Surname]"
}
}
So, there's two questions here.
1) If I'm requesting a web token in code for the private database, why is it only allowing me to interact with the public database? It feels like it's providing a web token that's only valid for the public database, regardless of the database I add the action to.
2) What are the security implications of validating a user against the public database like this? The token should expire in 30 minutes, which helps from that front.
To prove that a web token works against the private database, I updated "Sign In Callback" int the Dashboard, copied the resulting ckWebAuthToken and was able to get access to the private database through PostMan, so there's no issue from that end. It seems as if the issue lies entirely with the web token returned from the iOS code.
My guess is that it's because the Users record type in CloudKit is always stored in the public database in every CloudKit container.
There shouldn't be any security risks with this validation against the public databse. In my opinion, Apple shouldn't have ever named it "public" because it's not really public. It's just generally available to the users of the app, but only the application and authenticated users can transact with the database as defined by the developer. It's not available to the public.
I'm going to assume you are doing something fancy with this authentication flow, since authenticating a user on an iOS device doesn't require passing around the ckWebAuthToken. :)
Related
I use GoogleSignIn for iOS (GoogleSignIn-iOS), v6.1.0, in my iOS app.
All calls to my backend have the idToken in the request header.
The id token is verified in the backend. Here I also need to retrieve the users email and name.
(see also: https://developers.google.com/identity/sign-in/ios/backend-auth)
After a new SignIn with GIDSignIn.sharedInstance.signIn everything works fine.
GIDSignIn.sharedInstance.currentUser.profile contains email and name.
When sending the idToken to the backend, the Verifier gives me name and email in its payload, too.
Before I do a backend request, I get a valid (=not expired) idToken, with the following code:
private static func refreshToken(_ authentication: GIDAuthentication) async throws -> GIDAuthentication {
try await withCheckedThrowingContinuation { continuation in
authentication.do { authentication, error in
if let authentication = authentication {
continuation.resume(returning: authentication)
} else if let error = error {
Log.warn("Google SignIn refreshToken failed with -> \(error)")
continuation.resume(throwing: error)
}
}
}
}
I use the following code to get the idToken, before I create the request for my URLSession.
func idToken() async -> String {
do {
guard let user = GIDSignIn.sharedInstance.currentUser else {
Log.error("No GID user to get idToken from")
return ""
}
currentAuth = try await Self.refreshToken(user.authentication) //currentAuth is a class variable
return currentAuth?.idToken ?? ""
} catch {
print("Error during Google SignIn idToken retrieval \(error)")
return ""
}
}
And now my problem comes:
The idToken is refreshed properly. It is valid for another hour, and the verifier in my backend accepts it.
But I can't get the users name from the verified payload data in the backend, the name field is null.
Same happens when I use GIDSignIn.sharedInstance.restorePreviousSignIn (which I call on every app re-start, to do the silent sign in. (But in the app, the values are there in the updated users object profile)
It seems to me, that when the idToken gets refreshed, that it looses the profile scope.
I hope someone can help me with this, or at least explain the behaviour to me.
Thank in advance :)
Update
I checked the idTokens on https://jwt.io.
They are valid, but after the refresh, the jwt payload definitely is missing the profile data, like the users name.
I waited one day and tried again. Now the silent signin after app start gives me a complete idToken with jwt payload including name, but only once. After an hour, when the idToken gets refreshed, the idToken is again missing the profile information
Unfortunately I got no hint here, so I solved my problem as follows.
I hope this approach can save time for some others in the future.
I only require the profile data, when the user logs in to the backend the first time and a new user record is created in the backend.
In all other calls, where I need the JWT for authentication, I only rely on the basic information (ID, email) and handle all other values as optional values.
So I check the users name, if it is available. Otherwise the ID and a valid token is of course sufficent for authentication.
I've been working with the Amplify SDK to get federatedSignIn working with my iOS app with "Sign in with Apple" and Cognito to eventually make calls to API Gateway / Lambda functions.
TL;DR : My access token does not appear to be "automatically included in outbound requests" to my API as per the last paragraph of this section of the docs : Cognito User pool authorization
I have successfully authenticated using the tutorial found here Authentication Getting Started and other various Youtube videos on the Amazon Web Services channel.
Upon successful sign in through Apple I'm given an ASAuthorizationAppleIDCredential object. This contains the user field (token) which I pass to the Amplify.Auth class using the following Swift code :
func signIn (with userId: String)
{
guard
let plugin = try? Amplify.Auth.getPlugin(for: AWSCognitoAuthPlugin().key),
let authPlugin = plugin as? AWSCognitoAuthPlugin,
case .awsMobileClient (let client) = authPlugin.getEscapeHatch()
else
{
return
}
client.federatedSignIn(providerName: AuthProvider.signInWithApple.rawValue, token: userId) { (state, error) in
if let unwrappedError = error
{
print (unwrappedError)
}
else if let unwrappedState = state
{
print ("Successful federated sign in:", unwrappedState)
}
}
}
All appears to be successful and to double check I use the following bit of code to ensure I'm authorized :
func getCredentialsState (for userId:String)
{
let provider = ASAuthorizationAppleIDProvider()
provider.getCredentialState(forUserID: userId) { (credentialsState, error) in
if let unwrappedError = error
{
print (unwrappedError)
}
switch credentialsState
{
case .authorized:
print ("User Authorized")
case .notFound, .revoked:
print ("User Unauthenticated")
case .transferred:
print ("User Needs Transfer")
#unknown default:
print ("User Handle new use cases")
}
}
}
In the console I see "User Authorized" so everything appears to be working well.
However when I then go to make a call to Amplify.API.post I get the following error:
[Amplify] AWSMobileClient Event listener - signedOutFederatedTokensInvalid
Failed APIError: Failed to retrieve authorization token.
Caused by:
AuthError: Session expired could not fetch cognito tokens
Recovery suggestion: Invoke Auth.signIn to re-authenticate the user
My function for doing the POST is as follows :
func postTest ()
{
let message = #"{'message": "my Test"}"#
let request = RESTRequest (path: "/test", body: message.data(using: .utf8))
Amplify.API.post (request:request)
{
result in switch result
{
case .success(let data):
let str = String (decoding: data, as: UTF8.self)
print ("Success \(str)")
case .failure(let apiError):
print ("Failed", apiError)
}
}
}`
I then went into the API Gateway UI and changed the generated Method Request on my resource from AWS IAM to my Cognito User Pool Authorizer thinking this was the issue. I also changed the awsAPIPlugin authorizationType to "AMAZON_COGNITO_USER_POOLS" in my amplifyconfiguration.json file. This unfortunately did not have any affect.
I've seen posts such as this issue User is not created in Cognito User pool for users logging in with Google federated login #1937 where people discuss the problem of having to to use a web ui to bring up the social sign in. I understand that Apple will reject your app sometimes for this. Therefore this is not a solution.
I then found this post which seems to resolve the issue however this appears to use the old version of the SDK? Get JWT Token using federatedSignIn #1276
I'm not great with Swift (I'm still an Objective C expert, but am slowly learning Swift) so I'm uncertain which path to go here and whether this is actually a solution? It does seem to be quite more complicated than the function I have that does my POST? The RESTRequest does seem to be a simple and easy solution but I'm uncertain how to pass it the Authorization token (or even how to get the token if it is needed here).
However, everything I've read about the SDK is that the authorization should be handled automatically in the background according the docs in my first link above. Specifically pointed out, again, here : Cognito User pool authorization. The last paragraph here states 👍
With this configuration, your access token will automatically be included in outbound requests to your API, as an Authorization header.
Therefore, what am I missing here as this does not appear to automatically include my access token to my outbound requests to my API?
I'm trying use Apple's new DeviceCheck API to verify that network calls in my app are actually coming from an uncompromised version of my app.
Documentation
After successfully verifying a key’s attestation, your server can
require the app to assert its legitimacy for any or all future server
requests. The app does this by signing the request. In the app, first
obtain a unique, one-time challenge from the server. You use a
challenge here, like for attestation, to avoid replay attacks. Then
combine the challenge with the server request to create a hash:
let challenge = <# A string from your server #>
let request = [ "action": "getGameLevel",
"levelId": "1234",
"challenge": challenge ]
guard let clientData = try? JSONEncoder().encode(request) else { return }
let clientDataHash = Data(SHA256.hash(data: clientData))
Use this hash and the key identifier that you generated earlier to
create an assertion object by calling the
generateAssertion(_:clientDataHash:completionHandler:) method:
service.generateAssertion(keyId, clientDataHash: clientDataHash) { assertion, error in
guard error == nil else { /* Handle the error. */ }
// Send the assertion and request to your server.
}
I'm trying to add this assertion functionality to my Swift function, which is a helper function that calls a Firebase Cloud Function.
I want the assertion object to be passed as data to the Cloud Function, to verify that the Cloud Function is being called from an uncompromised version of my app:
func callFunction(name: String, data: [String:Any?], completion: #escaping (HTTPSCallableResult?, Error?)->()){
var functions = Functions.functions()
functions.httpsCallable(name).call(data){ (result, error) in
completion(result, error)
}
}
(Example of callFunction() being used below):
let data: [String:Any?] = [
"gameId": self.game?.id,
"answer": answer,
"answeredAt": Date().millisecondsSince1970
]
callFunction(name: "answerQuestion", data: data){ res, err in
print("Submitted answer: \(res.debugDescription) | Error: \(err)")
if let err = err {
self.game?.question?.state = .initial
}
}
To generate the assertion object to send to my server (cloud function), it requires me to generate a challenge as stated above. However I'm not sure how to generate this challenge.
Apple says it should be "A string from your server". But I'm not sure what the string should be. Is it meant to be a dynamic string based on the user's UID? A Base64-encoded string of the user ID and a static secret string? And when I try to retrieve this string from the server, the user will just be able to read it as they can see incoming network JSON (I presume I would retrieve the string with a Cloud Function call) - so it seems pointless as it's not a secret string anymore?
Any idea how I can make the challenge work securely?
The point of the challenge is to avoid replay attacks, so it can be any randomised string. A UUID would be fine. It doesn't need to be a secret.
The challenge string is combined with the transaction data and a hash is generated. You send the hash to and you send that to generateAssertion and receive the assertion object. You then send this to your server along with the request data.
Now your server can combine the received request data with the challenge (which it knows, since it sent it to the client initially), generate the same hash and validate the attestation.
The server-side attestation article provides detail on the challenge data:
Provide a Challenge
Every time your app needs to communicate attestation data to your server, the app first asks the server for a unique, one-time challenge. App Attest integrates this challenge into the objects that it provides, and that your app sends back to your server for validation. This makes it harder for an attacker to implement a replay attack.
When asked for a challenge, provide your app with a randomized data value, and remember the value for use when verifying the corresponding attestation or assertion objects sent by the client. How you use the challenge data depends on the kind of object that you need to validate.
I'm developing a simple web API with Vapor. To give more context, I'm newbie in backend development.
The consumer of the API is going to be an iOS app. Currently, I don't need the users to sign up to use the app. And I would like to keep it like that.
On the other hand, I would like to have some authentication to avoid that anyone could use the API I'm developing.
Looking for information I've found how implement authentication. But the examples I've seen are based on creating users in the backend for each user of the app. What I don't want to do. I would like to use an api-key as we do normally when we use third-party api's.
How could I have "api-key authentication" with Vapor ??
Or, should I just create an unique user/password that it's shared by all the users of the iOS app (that use the API) and then use basic or token authentication?
Thank you very much!
Carlos
One way around this is to create a fake token and use either the TokenAuthenticationMiddleware or more likely a custom middleware that checks the incoming token.
However, be aware that there is nothing stopping anyone from inspecting the traffic coming from your app to view the token and then using that to access your API.
Following Tim idea and an example from the book Server Side with Vapor (by the Raywenderlich.com Tutorial Team) I've created this custom middleware that makes the work:
final class SecretMiddleware: Middleware {
let secret: String
init(secret: String) {
self.secret = secret
}
func respond(to request: Request, chainingTo next: Responder) throws -> Future<Response> {
guard let bearerAuthorization = request.http.headers.bearerAuthorization else {
throw Abort(.unauthorized, reason: "Missing token")
}
guard bearerAuthorization.token == secret else {
throw Abort(.unauthorized, reason: "Wrong token")
}
return try next.respond(to: request)
}
}
extension SecretMiddleware: ServiceType {
static func makeService(for worker: Container) throws -> SecretMiddleware {
let secret: String
switch worker.environment {
case .development:
secret = "foo"
default:
guard let envSecret = Environment.get("SECRET") else {
let reason = "No SECRET set on environment."
throw Abort(.internalServerError, reason: reason)
}
secret = envSecret
}
return SecretMiddleware(secret: secret)
}
}
I want to allow a user to enter their email address/password in a field. Upon continuing, I want to run a check to see if that user already exists. If they do, log them in and continue with app, if they do not, move to account creation flow where they will be instructed to add name, phone number, etc.
I cannot for the life of me find documentation on how to log a user in using AWS Cognito. I should be able to pass email/pass in a call and get a response back that says User Exists/User does not exist or whatever! Am I missing something here?
Any help would be greatly appreciated. I've scoured the documentation..this is my last resort.
In the current SDK, calling getUser on your AWSCognitoIdentityUserPool just constructs the in-memory user object. To make the call over the network, you need to call the getSession method on the constructed user. Here's a Swift 3 method I wrote to check whether an email is available:
/// Check whether an email address is available.
///
/// - Parameters:
/// - email: Check whether this email is available.
/// - completion: Called on completion with parameter true if email is available, and false otherwise.
func checkEmail(_ email: String, completion: #escaping (Bool) -> Void) {
let proposedUser = CognitoIdentityUserPoolManager.shared.pool.getUser(email)
UIApplication.shared.isNetworkActivityIndicatorVisible = true
proposedUser.getSession(email, password: "deadbeef", validationData: nil).continueWith(executor: AWSExecutor.mainThread(), block: { (awsTask) in
UIApplication.shared.isNetworkActivityIndicatorVisible = false
if let error = awsTask.error as? NSError {
// Error implies login failed. Check reason for failure
let exceptionString = error.userInfo["__type"] as! String
if let exception = AWSConstants.ExceptionString(rawValue: exceptionString) {
switch exception {
case .notAuthorizedException, .resourceConflictException:
// Account with this email does exist.
completion(false)
default:
// Some other exception (e.g., UserNotFoundException). Allow user to proceed.
completion(true)
}
} else {
// Some error we did not recognize. Optimistically allow user to proceed.
completion(true)
}
} else {
// No error implies login worked (edge case where proposed email
// is linked with an account which has password 'deadbeef').
completion(false)
}
return nil
})
}
For reference, my ExceptionString enum looks like this:
public enum ExceptionString: String {
/// Thrown during sign-up when email is already taken.
case aliasExistsException = "AliasExistsException"
/// Thrown when a user is not authorized to access the requested resource.
case notAuthorizedException = "NotAuthorizedException"
/// Thrown when the requested resource (for example, a dataset or record) does not exist.
case resourceNotFoundException = "ResourceNotFoundException"
/// Thrown when a user tries to use a login which is already linked to another account.
case resourceConflictException = "ResourceConflictException"
/// Thrown for missing or bad input parameter(s).
case invalidParameterException = "InvalidParameterException"
/// Thrown during sign-up when username is taken.
case usernameExistsException = "UsernameExistsException"
/// Thrown when user has not confirmed his email address.
case userNotConfirmedException = "UserNotConfirmedException"
/// Thrown when specified user does not exist.
case userNotFoundException = "UserNotFoundException"
}
Some clarification is in order. Cognito has several parts. The part that does "Authentication" (which is what you are talking about) is called "Cognito User Pools". Not to be confused with Cognito Federated Identity Pools.
With User Pools you can create usernames and password combinations with attributes, and these can be used to authenticate and deliver a persistent, cross device, Cognito Federated identity identityId to a user (across multiple devices).
Once logged in, the Federated Identity Pool is hooked to roles which can get your "Authorized" to use AWS services (like Dynamo DB etc).
It can be tricky to get all these parts working together and AWS has an online site called "Mobile Hub" that will build code for you and download an xcode project. This process configures the Federated Identity Pool and the User Pool correctly, and connects them all up to a set of example code.
Connecting the credentials provider to the user pool to the identity pool is a bit counterintuitive, but the AWSIdentityManager in the aws-mobilehub-helper-ios on github manages all that for you. So I would recommend starting with mobile hub on the console.
Cognito is a somewhat confusing system, here is a link to a brief powerpoint that hits the highlights of how it works (for people that can't understand the AWS docs (like me)).
With that said, "how to check if a user already exists?"
The most reasonable approach is to create the user (via signup), and get a reject if the name is in use, and suggest that your user try a different username. With respect to the email being in use, you will get that reject upon confirmation (signup sends confirmation id's by email and/or via text). This can be overridden to reclaim the email address, or you can do a test beforehand to see if the email is in use by attempting to log in and looking at the failure code.
you can fetch the user as the other answer suggests, however if you have established in user pools an alias for login (like email) you will find this problematic, because this just tells you if someone has the user name, not if someone is already using the email address, and you will get a reject later at confirmation time.
ListUsers is now a nice way to check for existing usernames.
https://docs.aws.amazon.com/cognito-user-identity-pools/latest/APIReference/API_ListUsers.html
You can also look for existing emails, phone numbers, and other default attributes.
Here is a simple .NET example:
Dim userRequest = New ListUsersRequest With {
.UserPoolId = "poolId",
.Filter = "username = bob#email.com"
}
Dim response = amazonCognitoIdentityProviderClient.ListUsers(userRequest)
Debug.WriteLine(response.Users.Count)