Swift & Firebase: Detect Revoked Token

Where I get from IOS Firebase API the Apple Revoke Tokens Endpoint Parameters (client_id, client_secret, token)

apple-token-revoke-in-firebase

This document describes how to revoke the token of Sign in with Apple in the Firebase environment.

In accordance with Apple's review guidelines, apps that do not take action by June 30, 2022 may be removed.

A translator was used to write this document, so I apologize whenever you feel weird about these sentences and describes.

This document uses Firebase's Functions, and if Firebase provides related function in the future, I recommend using it.

The whole process is as follows.

  1. Get authorizationCode from App where user log in.
  2. Get a refresh token with no expiry time using authorizationCode with expiry time.
  3. After saving the refresh token, revoke it when the user leaves the service.

You can get a refresh token at https://appleid.apple.com/auth/token and revoke at https://appleid.apple.com/auth/revoke.

Getting started

If you have implemented Apple Login using Firebase, you should have ASAuthorizationAppleIDCredential somewhere in your project.

In my case, it is written in the form below.

  func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) {
if let appleIDCredential = authorization.credential as? ASAuthorizationAppleIDCredential {
guard let nonce = currentNonce else {
fatalError("Invalid state: A login callback was received, but no login request was sent.")
}
guard let appleIDToken = appleIDCredential.identityToken else {
print("Unable to fetch identity token")
return
}
guard let idTokenString = String(data: appleIDToken, encoding: .utf8) else {
print("Unable to serialize token string from data: \(appleIDToken.debugDescription)")
return
}
// Initialize a Firebase credential.
let credential = OAuthProvider.credential(withProviderID: "apple.com",
IDToken: idTokenString,
rawNonce: nonce)
// Sign in with Firebase.
Auth.auth().signIn(with: credential) { (authResult, error) in
if error {
// Error. If error.code == .MissingOrInvalidNonce, make sure
// you're sending the SHA256-hashed nonce as a hex string with
// your request to Apple.
print(error.localizedDescription)
return
}
// User is signed in to Firebase with Apple.
// ...
}
}
}

What we need is the authorizationCode. Add the following code under guard where you get the idTokenString.

...

guard let idTokenString = String(data: appleIDToken, encoding: .utf8) else {
print("Unable to serialize token string from data: \(appleIDToken.debugDescription)")
return
}

// Add new code below
if let authorizationCode = appleIDCredential.authorizationCode,
let codeString = String(data: authorizationCode, encoding: .utf8) {
print(codeString)
}

...

Once you get this far, you can get the authorizationCode when the user log in.

However, we need to get a refresh token through authorizationCode, and this operation requires JWT, so let's do this with Firebase functions.
Turn off Xcode for a while and go to your code in Firebase functions.

If you have never used functions, please refer to https://firebase.google.com/docs/functions.

In Firebase functions, you can use JavaScript or TypeScript, for me, I used JavaScript.

First, let's declare a function that creates a JWT globally. Install the required packages with npm install.

There is a place to write route of your key file and ID(Team, Client, Key), so plz write your own information.

If you do not know your ID information, please refer to the relevant issue. https://github.com/jooyoungho/apple-token-revoke-in-firebase/issues/1

function makeJWT() {

const jwt = require('jsonwebtoken')
const fs = require('fs')

// Path to download key file from developer.apple.com/account/resources/authkeys/list
let privateKey = fs.readFileSync('AuthKey_XXXXXXXXXX.p8');

//Sign with your team ID and key ID information.
let token = jwt.sign({
iss: 'YOUR TEAM ID',
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 120,
aud: 'https://appleid.apple.com',
sub: 'YOUR CLIENT ID'

}, privateKey, {
algorithm: 'ES256',
header: {
alg: 'ES256',
kid: 'YOUR KEY ID',
} });

return token;
}

The above function is returned by creating JWT based on your key information.

Now, let's get the Refresh token with AuthorizationCode.

We will add a function called getRefreshToken to functions.

exports.getRefreshToken = functions.https.onRequest(async (request, response) => {

//import the module to use
const axios = require('axios');
const qs = require('qs')

const code = request.query.code;
const client_secret = makeJWT();

let data = {
'code': code,
'client_id': 'YOUR CLIENT ID',
'client_secret': client_secret,
'grant_type': 'authorization_code'
}

return axios.post(`https://appleid.apple.com/auth/token`, qs.stringify(data), {
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
})
.then(async res => {
const refresh_token = res.data.refresh_token;
response.send(refresh_token);

});

});

When you call the above function, you get the code from the query and get a refresh_token.
For code, this is the authorizationCode we got from the app in the first place.
Before connecting to the app, let's add a revoke function as well.


exports.revokeToken = functions.https.onRequest( async (request, response) => {

//import the module to use
const axios = require('axios');
const qs = require('qs');

const refresh_token = request.query.refresh_token;
const client_secret = makeJWT();

let data = {
'token': refresh_token,
'client_id': 'YOUR CLIENT ID',
'client_secret': client_secret,
'token_type_hint': 'refresh_token'
};

return axios.post(`https://appleid.apple.com/auth/revoke`, qs.stringify(data), {
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
})
.then(async res => {
console.log(res.data);
});
});

The above function revokes the login information based on the refresh_token we got.

So far we have configured our functions, and when we do 'firebase deploy functions' we will have something we added to the Firebase functions console.

img

Now back to Xcode.

Call the Functions address in the code you wrote earlier to save Refresh token.

I saved it in UserDefaults, You can save it in the Firebase database.

...

// Add new code below
if let authorizationCode = appleIDCredential.authorizationCode, let codeString = String(data: authorizationCode, encoding: .utf8) {

let url = URL(string: "https://YOUR-URL.cloudfunctions.net/getRefreshToken?code=\(codeString)".addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "https://apple.com")!

let task = URLSession.shared.dataTask(with: url) {(data, response, error) in

if let data = data {
let refreshToken = String(data: data, encoding: .utf8) ?? ""
print(refreshToken)
UserDefaults.standard.set(refreshToken, forKey: "refreshToken")
UserDefaults.standard.synchronize()
}
}
task.resume()

}

...

At this point, the user's device will save the refresh_token as UserDefaults when logging in.
Now all that's left is to revoke when the user leaves the service.

  func removeAccount() {
let token = UserDefaults.standard.string(forKey: "refreshToken")

if let token = token {

let url = URL(string: "https://YOUR-URL.cloudfunctions.net/revokeToken?refresh_token=\(token)".addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "https://apple.com")!

let task = URLSession.shared.dataTask(with: url) {(data, response, error) in
guard data != nil else { return }
}

task.resume()

}
...
//Delete other information from the database...
FirebaseAuthentication.shared.signOut()
}

If we've followed everything up to this point, our app should have been removed from your Settings - Password & Security > Apps Using Apple ID.

Thank you.

Check for invalid user-token firebase in didFinishLaunchingWithOptions

I found the following solution:

in didFinishLaunchingWithOptions add the following piece of code:

if Auth.auth?.currentUser == nil {
// You need to prompt the user login interface
} else {
Auth.auth().currentUser?.reload(completion: { (error) in
if error != nil {
if let err = error as NSError?{
if let error = AuthErrorCode(rawValue: err.code){
switch error{
// You need to prompt the user login interface
case .invalidCredential: print("Invalid credentials")
case .invalidUserToken: print("Invalid User Token")
case .userTokenExpired: print("User Token Expired")
case .invalidCustomToken: print("Invalid Custom Token")
case .customTokenMismatch: print("Custom token mismatch")
case .userDisabled: print("User disabled")
case .userNotFound: print("User not found")
default: print("call default error")
}
}
}
}
else {
print("Valid Token")
}
})
}

Ah, and don't forget to initialize Firebase before running this code (else you won't have access to Firebase Auth)

I can't reauthenticate or revoke access token of the user in Firebase

Using an expired authentication token won't allow you to authenticate with Firebase. So first you must get a fresh ID token.

If the GoogleSignInAccount stored on your device supports it (you have a stored refresh token), you should be able to use silentSignIn() to obtain a fresh ID token which you can then pass along to Firebase.

The below flow is roughly stamped out from JavaScript. Expect typos and bugs, but it should point you (or someone else) in the right direction.

public void deleteCurrentFirebaseUser() {
final FirebaseUser user = FirebaseAuth.getInstance().getCurrentUser();
if (user == null) {
// TODO: Throw error or show message to user
return;
}

// STEP 1: Get a new ID token (using cached user info)
Task<GoogleSignInAccount> task = mGoogleSignInClient.silentSignIn();
task
.continueWithTask(Continuation<GoogleSignInAccount, Task<AuthResult>>() {
@Override
public void then(Task<GoogleSignInAccount> silentSignInTask) {
GoogleSignInAccount acct = silentSignInTask.getResult();
// STEP 2: Use the new token to reauthenticate with Firebase
AuthCredential credential = GoogleAuthProvider.getCredential(acct.getIdToken(), null);
return mAuth.reauthenticate(credential);
}
})
.continueWithTask(Continuation<AuthResult, Task<Void>>() {
@Override
public void then(Task<AuthResult> firebaseSignInTask) {
AuthResult result = firebaseSignInTask.getResult();
// STEP 3: If successful, delete the user
FirebaseUser user = result.getUser();
return user.delete();
}
})
.addOnCompleteListener(this, new OnCompleteListener<Void>() {
@Override
public void onComplete(@NonNull Task<Void> deleteUserTask) {
// STEP 4: Handle success/errors
if (task.isSuccessful()) {
// The user was successfully deleted
Log.d(TAG, "deleteCurrentFirebaseUser:success");
// TODO: Go to sign-in screen
} else {
// The user was not deleted
// Google sign in, Firebase sign in or Firebase delete user operation failed.
Log.w(TAG, "deleteCurrentFirebaseUser:failure", task.getException());
Snackbar.make(mBinding.mainLayout, "Failed to delete user.", Snackbar.LENGTH_SHORT).show();

final Exception taskEx = task.getException();
if (taskEx instanceof ApiException) {
ApiException apiEx = (ApiException) taskEx;
int googleSignInStatusCode = apiEx.getStatusCode();
// TODO: Handle Google sign-in exception based on googleSignInStatusCode
// e.g. GoogleSignInStatusCodes.SIGN_IN_REQUIRED means the user needs to do something to allow background sign-in.
} else if (taskEx instanceof FirebaseAuthException) {
// One of:
// - FirebaseAuthInvalidUserException (disabled/deleted user)
// - FirebaseAuthInvalidCredentialsException (token revoked/stale)
// - FirebaseAuthUserCollisionException (does the user already exist? - it is likely that Google Sign In wasn't originally used to create the matching account)
// - FirebaseAuthRecentLoginRequiredException (need to reauthenticate user - it shouldn't occur with this flow)

FirebaseAuthException firebaseAuthEx = (FirebaseAuthException) taskEx;
String errorCode = firebaseAuthEx.getErrorCode(); // Contains the reason for the exception
String message = firebaseAuthEx.getMessage();
// TODO: Handle Firebase Auth exception based on errorCode or more instanceof checks
} else {
// TODO: Handle unexpected exception
}
}
}
});
}

An alternative to the above would be to use a Callable Cloud Function that uses the Admin SDK's Delete User function as commented by @example. Here's a bare-bones implementation of that (without any confirmation step):

exports.deleteMe = functions.https.onCall((data, context) => {
if (!context.auth) {
throw new functions.https.HttpsError('failed-precondition', 'The function must be called while authenticated.');
}

const uid = context.auth.uid;

return admin.auth().deleteUser(uid)
.then(() => {
console.log('Successfully deleted user');
return 'Success!';
})
.catch(error => {
console.error('Error deleting user: ', error);
throw new functions.https.HttpsError('internal', 'Failed to delete user.', error.code);
});
});

Which would be called using:

FirebaseFunctions.getInstance()
.getHttpsCallable("deleteMe")
.call()
.continueWith(new Continuation<HttpsCallableResult, Void>() {
@Override
public void then(@NonNull Task<HttpsCallableResult> task) {
if (task.isSuccessful()) {
// deleted user!
} else {
// failed!
}
}
});

If you use the Cloud Functions approach, I highly recommend sending a confirmation email to the user's linked email address before deleting their account just to ensure it's not some bad actor. Here's a rough draft of what you would need to achieve that:

exports.deleteMe = functions.https.onCall((data, context) => {
if (!context.auth) {
throw new functions.https.HttpsError('failed-precondition', 'The function must be called while authenticated.');
}

const uid = context.auth.uid;

return getEmailsForUser(context.auth)
.then(userEmails => {
if (data.email) { // If an email was provided, use that
if (!userEmails.all.includes(data.email)) { // Throw an error if the provided email isn't linked to this user
throw new functions.https.HttpsError('failed-precondition', 'User is not linked to provided email.');
}
return sendAccountDeletionConfirmationEmail(uid, data.email);
} else if (userEmails.primary) { // If available, send confirmation to primary email
return sendAccountDeletionConfirmationEmail(uid, userEmails.primary);
} else if (userEmails.token) { // If not present, try the authentication token's email
return sendAccountDeletionConfirmationEmail(uid, userEmails.token);
} else if (userEmails.all.length == 1) { // If not present but the user has only one linked email, try that
// If not present, send confirmation to the authentication token's email
return sendAccountDeletionConfirmationEmail(uid, userEmails.all[0]);
} else {
throw new functions.https.HttpsError('internal', 'User has multiple emails linked to their account. Please provide an email to use.');
}
})
.then(destEmail => {
return {message: 'Email was sent successfully!', email: email}
});
});

exports.confirmDelete = functions.https.onRequest((req, res) => {
const uid = request.params.uid;
const token = request.params.token;
const nextPath = request.params.next;

if (!uid) {
res.status(400).json({error: 'Missing uid parameter'});
return;
}

if (!token) {
res.status(400).json({error: 'Missing token parameter'});
return;
}

return validateToken(uid, token)
.then(() => admin.auth().deleteUser(uid))
.then(() => {
console.log('Successfully deleted user');
res.redirect('https://your-app.firebaseapp.com' + (nextPath ? decodeURIComponent(nextPath) : ''));
})
.catch(error => {
console.error('Error deleting user: ', error);
res.json({error: 'Failed to delete user'});
});
});

function getEmailsForUser(auth) {
return admin.auth().getUser(auth.uid)
.then(record => {
// Used to create array of unique emails
const linkedEmailsMap = {};

record.providerData.forEach(provider => {
if (provider.email) {
linkedEmailsMap[provider.email] = true;
}
});

return {
primary: record.email,
token: auth.token.email || undefined,
all: Object.keys(linkedEmailsMap);
}
});
}

function sendAccountDeletionConfirmationEmail(uid, destEmail) {
const token = 'oauhdfaskljfnasoildfma'; // TODO: Create URL SAFE token generation logic

// 'confirmation-tokens' should have the rules: { ".read": false, ".write": false }
return admin.database().ref('confirmation-tokens/'+uid).set(token)
.then(() => {
// Place the UID and token in the URL, and redirect to "/" when finished (next=%2F).
const url = `https://your-app.firebaseapp.com/api/confirmDelete?uid=${uid}&${token}&next=%2F`;

const emailBody = 'Please click <a href="' + url + '">here</a> to confirm account deletion.<br/><br/>Or you can copy "'+url+'" to your browser manually.';

return sendEmail(destEmail, emailBody); // TODO: Create sendEmail
})
.then(() => destEmail);
}

function validateToken(uid, token) {
return admin.database().ref('confirmation-tokens/'+uid).once('value')
.then((snapshot) => {
if (snapshot.val() !== token) {
throw new Error('Token mismatch!');
}
});
}

Firebase authentication not revoked when user deleted?

Short answer: by design, or more accurately, not applicable in this case.

During auth, FirebaseSimpleLogin generates a token. Once the token is given to a client, it remains valid until it expires. Thus, when you delete the user account in simple login, this does not somehow go to the client's machine and remove the token. This is a pretty standard auth model, and the expiration length on the token (configurable in Forge) is the key constraint for security.

If you want to revoke logins immediately, then simple login is not the right tool for the job. You'll want to use custom login and generate your own tokens. There are some great discussions on revokable tokens, so I'll defer you to those, since that's outside the purview of your question.

Revoke token for iOS Guideline 5.1.1 (v)

Since Apple's revoke API requires a JWT signed with the developer's key, it cannot be processed only by the client.

Before Firebase officially supports it, you need to configure the server yourself, and you can implement it through Firebase's Functions. Please see the link below.

https://github.com/jooyoungho/apple-token-revoke-in-firebase



Related Topics



Leave a reply



Submit