Today, users can choose from a variety of identity providers to suit their needs. Sign in with Apple is the latest addition to the lineup of social providers, offering a great experience to Apple users.
Sign in with Apple is optimized to work seamlessly on all Apple devices; from tvOS to macOS. Users can use the familiar FaceID or TouchID to quickly and securely sign into third-party apps. Apple also promises not to track or profile users and offers the Hide My Email option for those who would prefer to keep their email address private.
Sign in with Apple is an attractive sign-in option for many Apple users. However, it has its share of nuances. Let’s take a look at some of these nuances and how they might impact users of third-party apps.
Sign in with Apple on Other Platforms
Sign in with Apple redirects users on other platforms to a web portal where they first enter their Apple ID credentials. Users then verify with a code sent to their Apple devices or on SMS. Optionally, users can skip the second-factor step for 30 days.
At the moment, it doesn’t seem like Apple maintains persistent sessions for users logging in on other platforms. Typically, identity providers maintain the user session after the user enters their credentials for the first time. This session is used when authenticating to client apps so that users don’t have to re-enter their credentials constantly.
Apple’s second-factor works by either sending a code on a trusted Apple device or by sending the code over SMS. This is restrictive when compared to other social providers who offer a wider selection of second-factor options that work well on multiple platforms. Some even use standards-based second factors such as TOTP in addition to proprietary ones.
For many users, the iPhone is the only Apple device they own. For these users, a lost or stolen iPhone would leave them with no other options to access apps that rely on Sign in with Apple. In general, the more ways a user can verify their identity, the less likely that user is to lose access to their account.
"Apple’s second-factor works by either sending a code on a trusted Apple device or by sending the code over SMS."
Tweet This
Bottom line: Sign in with Apple is a fantastic experience within the Apple ecosystem. Although the lack of persistent sessions and limited second-factor options diminishes the experience on other platforms, potentially putting some users at risk of account lockout.
Duplicate Accounts
When introducing Sign in with Apple into an existing application, there is a high likelihood that many users who had previously used other providers will start using Sign in with Apple. When this happens, users lose access to their original account’s data. This can be frustrating for the user and might deter them from using Sign in with Apple or the app itself. This scenario is known as user duplication and has less to do with Sign in with Apple as a technology and more to do with its status as a fledgling provider.
Sometimes, duplicated users can be detected if the accounts share a common attribute, such as a verified email address. However, Sign in with Apple gives users the option to hide their email address behind an alias unique to each app, so we can’t rely on this method.
"Sign in with Apple is a fantastic experience within the Apple ecosystem."
Tweet This
Apple responds to the issue by integrating Sign in with Apple with iCloud Keychain, users with an existing account with a service are prompted to use their credentials instead. However, Keychain only stores email/username and password combinations, which isn’t very helpful in social login scenarios.
In the end, users still have to choose between enjoying the benefits of Sign in with Apple and keeping their existing account data. What if they could do both?
Account Linking with Sign in with Apple
We just talked about how Sign in with Apple presents difficulties to two groups of potential users: multiplatform users and users with existing accounts. One way we can improve the user experience for both groups of users is by using account linking.
When a user links two accounts, they can use any of the accounts to access the same user profile. Account linking can:
- Prevent account duplication, since users can just link their existing accounts with Sign in with Apple;
- Reduce the risk of account lockout by giving users more than one means to access their accounts;
- Improve the login experience for multiplatform users, who can use Sign in with Apple on their Apple devices and another provider on their non-Apple devices.
To showcase how account linking can be integrated into an existing app, we will build a custom login flow that extends the authentication experience to offer context-aware account linking suggestions to Apple users. For this, we will be using Auth0’s account linking in conjunction with Auth0 Rules.
Our flow will look like this:
As we have discussed, account linking can be used as a security measure to reduce the risk of account lockout. Because users tend to stick to defaults, we should always try to offer the safest option as the default. This makes the login flow the best time to offer account linking. By prompting users early, we can prevent issues such as account duplication and lockout before they occur.
Creating an Account Linking App
We start by creating a simple web app that authenticates users and follows up by asking Apple users to link another account. This is similar to the suggested account linking process for handling duplicate accounts described in Auth0’s documentation. In our case, since we cannot always be sure if an account is a duplicate, we will prompt all Apple users, even if we cannot detect a duplicate account.
Our app’s code will:
- Authenticate the user
- Identify Apple users
- Prompt the Apple user to sign in using another account
- Link the accounts
As we implement our custom flow, we will break down the process into individual code examples. For the purpose of the demonstration, our examples show an abridged version of the code and are not intended to be used as is. To see the full implementation, visit Github.
Set up authentication
To authenticate users, we will use Auth0’s Universal Login. To set this up, we first register our web app with Auth0. We show this process in the following video:
Once we have registered our app, we pass the relevant config variables to the passport-auth0 library, which handles authentication in our app. The library will do most of the heavy lifting for us.
The code should look like this:
/**
* Import config if .env is present
*/
import 'dotenv/config';
/**
* Import express and base utilities
*/
import Express from 'express';
import cookieSession from 'cookie-session';
import bodyParser from 'body-parser';
/**
* Import auth0 and passport helpers
*/
import passport from 'passport';
import Auth0 from 'passport-auth0';
import { ensureLoggedIn } from 'connect-ensure-login';
const app = Express();
const IS_PROD = process.env.NODE_ENV === 'production';
/**
* Setup rendering engine
*/
app.set('view engine', 'pug');
app.set('trust proxy');
/**
* Configure sessions, we use sessions
* in order to parse the body
*/
app.use(
cookieSession({
name: 'session',
secret: process.env.COOKIE_SECRET,
// cookie options
maxAge: 24 * 60 * 60 * 1000,
// enable these when we are no longer using localhost:
httpOnly: IS_PROD,
secure: IS_PROD
})
);
/**
* Inject bodyParser
*/
app.use(
bodyParser.urlencoded({
extended: true
})
);
// Configure Passport to use Auth0
var auth0 = new Auth0(
{
domain: process.env.AUTH0_DOMAIN,
clientID: process.env.CLIENT_ID,
clientSecret: process.env.CLIENT_SECRET,
callbackURL: process.env.BASE_URL + '/callback'
},
function(accessToken, refreshToken, extraParams, profile, done) {
return done(null, profile);
}
);
passport.use(auth0);
app.use(passport.initialize());
app.use(passport.session());
// Passport session setup.
passport.serializeUser(function(user, done) {
done(null, user);
});
passport.deserializeUser(function(obj, done) {
done(null, obj);
});
/**
* Complete authentication
*/
app.get('/callback', function(req, res, next) {
passport.authenticate('auth0', function(err, user, info) {
if (err) {
return next(err);
}
if (!user) {
return res.redirect('/login');
}
req.logIn(user, function(err) {
if (err) {
return next(err);
}
const returnTo = req.session.returnTo;
delete req.session.returnTo;
res.redirect(returnTo || '/');
});
})(req, res, next);
});
/**
* Route for passport
*/
app.get(
'/login',
passport.authenticate('auth0', {
scope: 'openid profile email'
})
);
/**
* Ensure everything beyond this point has a session
*/
app.use(
ensureLoggedIn({
redirectTo: '/login'
})
);
app.use((req, res) => res.end('Hi ' + req.user.displayName));
Identifying Apple users and prompting them
As we have discussed, our app redirects Apple users to a page where they are asked to link another account. We identify Apple users as users who are either:
- signing in using Sign in with Apple,
- or signing in from iOS or Mac OS with a non-Apple provider.
In the first case, we ask the user to link an account with another provider (in our demo app, we only show Google login, but our approach will work with any number of providers).
In the second case, we offer to link the user’s Apple account to improve their login experience on Apple devices. Although many Apple users will opt to sign in with their social accounts instead of Apple ID, they would still benefit from account linking, since Sign in with Apple offers a great sign-in experience across all Apple devices.
import useragent from 'useragent';
app.use((req, res, next) => {
req.ua = useragent.parse(req.get('User-Agent'));
next();
});
app.get('/start', (req, res) => {
const { user } = req;
if (user.provider === 'apple') {
return res.redirect('/prompt/apple');
} else {
const os = req.ua.os.family;
if (os === 'Mac OS X' || os === 'iOS') {
return res.redirect('/prompt/google')
}
}
return res.end('Error Unknown provider');
});
app.get('/prompt/apple', ensureProvider('apple'), (req, res) => {
res.render('apple', {
...req.user
});
});
app.get('/prompt/google', ensureProvider('google-oauth2'), (req, res) => {
res.render('google', {
...req.user
});
});
Storing the user to be linked
Once the user agrees to link their account, they can immediately sign in with their second provider. But before we redirect them to the login page, we need to save the user ID in our session so that we can link the two accounts later. We can achieve this with the following code:
app.get('/connect/:provider', (req, res, next) => {
if (req.session.identities && req.session.identities.length > 0) {
return next(new Error('Invalid step, linking already in progress'));
}
const userid = req.user.id;
const { provider } = req.params;
req.session.identities = [userid];
req.session.returnTo = '/connect/done'
req.session.expectedProvider = provider;
passport.authenticate('auth0', {
connection: provider,
prompt: 'login',
scope: 'openid profile email'
})(req, res, next);
});
Link the accounts
To link accounts, our application needs access to Auth0’s Management API with the read:users
and update:users
scopes. The following video demonstrates how to do this from the Auth0 Dashboard:
Using Auth0’s Node.js sdk, we create a management client in the app to link the user’s accounts:
import { ManagementClient } from 'auth0';
const management = new ManagementClient({
domain: process.env.AUTH0_DOMAIN,
clientId: process.env.CLIENT_ID,
clientSecret: process.env.CLIENT_SECRET
});
app.get('/connect/done', async (req, res) => {
if (!req.session.identities && req.session.identities.length < 1) {
return next(new Error('Callback cannot be called without first Id'));
}
if (!req.session.expectedProvider) {
return next(new Error('Callback cannot be called without expected provider'));
}
if (req.user.provider !== req.session.expectedProvider) {
return next(new Error('Callback cannot be called without expected provider'));
}
const userid = req.user.id;
req.session.identities.push(userid);
const [primaryUserId, secondaryUserId] = req.session.identities;
const [secondaryConnectionId, ...rest] = secondaryUserId.split('|');
const secondaryUserProviderId = rest.join('|');
try {
await management.linkUsers(primaryUserId, {
user_id: secondaryUserProviderId,
provider: secondaryConnectionId
});
res.render('final', {
primaryUser: primaryUserId,
secondaryUser: secondaryUserId
});
} catch (e) {
next(e);
}
});
Integrating the Account Linking App with the Login Flow Using Auth0 Rules
Now that we can link users’ accounts with our account linking web app, how can we integrate it into an existing login flow? As we will see, Auth0’s extensibility allows us to easily extend the default authentication flow provided by Auth0’s Universal Login. All we need to do is to redirect to our app after authentication.
Our extended experience looks like this:
- The user authenticates into the origin app
- If the user is an Apple user, redirect to the linking app, sending any relevant data
- Perform account linking
- Redirect back to the original app, completing the authentication transaction
The “glue” connecting our origin app to our linker app is Auth0’s Rules; custom functions executed by Auth0 as part of the authentication pipeline. Rules execute after the authentication is complete, but before Auth0 sends the user back to the app. As a result, they are a great tool for extending the authentication process.
In our case, we want to redirect from the origin app to the linking app once we have determined that the user is an Apple user. We implement this using two Rules: the first to determine whether the user should be redirected, and the second to perform the actual redirection. We can also add any data needed by the linker app at this point.
Detecting an Apple user
Just like we did in our linker app, our first Rule checks the user’s identity provider and platform to determine whether they are an Apple user. These can be found in the context object passed to Rules.
If we detect an Apple user, we set a new boolean property, shouldLink
. We add this property to the context object to indicate to our next Rule that we should redirect to the linking app.
function (user, context, callback) {
const useragent = require('useragent');
context.shouldLink = false;
let agent = useragent.parse(context.request.userAgent);
if (context.connection === 'apple') {
context.shouldLink = true;
} else {
if (agent.os.family === 'Mac OS X' || agent.os.family === 'iOS') {
context.shouldLink = true;
}
}
console.log('User is linkable?', context.shouldLink);
callback(null, user, context);
}
Redirecting a user using Rules
Rules can be used to redirect a user to a new page before completing the authentication transaction. All we need to do is set the context.redirect.url
property to the URL of our linker app.
function (user, context, callback) {
if (context.shouldLink && context.clientID !== configuration.APPLE_LINK_CLIENT_ID) {
console.log('Should redirect');
context.redirect = {
url: configuration.APPLE_LINK_APP_URI
};
}
return callback(null, user, context);
}
We are also checking the value of the client ID -- this is the ID of the client requesting Auth0 for information about the authenticating user. As we might recall, our linker app starts by asking Auth0 to authenticate the user. However, at that point, the user will have already logged in to Auth0, since our Rules are redirecting to the app after authentication.
Why does this matter? When we ask Auth0 to authenticate a user who already has an Auth0 session, the authentication flow isn’t triggered, but the Rules still are. Therefore, in order to avoid an infinite loop, we need to make sure the user is only redirected when the request is not coming from our linker app.
Changes to our app to integrate with Rules
Integrating our linker app with Rules requires some minor changes. When we redirect to our linker app from Rules, Auth0 sends a state parameter in the URL. After the account linking flow is complete, we need to send the state parameter back to Auth0 at the /continue endpoint. This will redirect us back to Auth0 and complete the authentication transaction. We wrap this logic in the following code:
app.get('/start', (req, _, next) => {
const { query } = req;
const { state } = query;
if (!state) {
return next(new Error('Unauthorized `state` is missing'));
}
if (req.session.auth0State) {
return next(new Error('Only one instance of redirect should run at a time'));
}
req.session.auth0State = state;
next();
});
// Redirect rule handler
app.use((req, res, next) => {
const { session } = req;
const { auth0State } = session;
res.redirectCallback = (queryParams = {}) => {
queryParams.state = auth0State;
const queryStr = Object.entries(queryParams)
.map(x => x.join('='))
.join('&');
const urlStr = `https://${process.env.AUTH0_DOMAIN}/continue?${queryStr}`;
req.session = null;
res.redirect(urlStr);
};
next();
});
Conclusion
Sign in with Apple is a great new way for companies to engage users. However, adopting the provider presents challenges for new and existing applications, such as handling account duplication and ensuring a great user experience on multiple platforms.
We showed how Auth0’s Extensibility can be used to offer context-aware account linking to users during the login process. While our demo app uses a simple algorithm to detect Apple users, real-world apps have a lot more context available to them, which can be used to improve the heuristic.
What are your thoughts on contextual account linking? Would you use this in your application? Comment below.
About Auth0
Auth0 by Okta takes a modern approach to customer identity and enables organizations to provide secure access to any application, for any user. Auth0 is a highly customizable platform that is as simple as development teams want, and as flexible as they need. Safeguarding billions of login transactions each month, Auth0 delivers convenience, privacy, and security so customers can focus on innovation. For more information, visit https://auth0.com.