0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-02-17 22:04:19 -05:00

feat(core,connector): support apple sign-in with email (#5454)

* feat(core,connector): support apple sign-in with email

* chore: add tests and changesets

* refactor: fix tests and improve changeset
This commit is contained in:
Gao Sun 2024-03-01 13:00:41 +08:00 committed by GitHub
parent 0457df0e35
commit 532454b923
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 196 additions and 29 deletions

View file

@ -0,0 +1,5 @@
---
"@logto/connector-apple": minor
---
support `scope`

View file

@ -0,0 +1,7 @@
---
"@logto/core": minor
---
support form post callback for social connectors
Add the `POST /callback/:connectorId` endpoint to handle the form post callback for social connectors. This usefull for the connectors that require a form post callback to complete the authentication process, such as Apple.

View file

@ -4,12 +4,13 @@ The official Logto connector for Apple social sign-in.
**Table of contents**
- [Apple connector](#apple-connector)
- [Get started](#get-started)
- [Enable Sign in with Apple for your app](#enable-sign-in-with-apple-for-your-app)
- [Create an identifier](#create-an-identifier)
- [Enable Sign in with Apple for your identifier](#enable-sign-in-with-apple-for-your-identifier)
- [Test Apple connector](#test-apple-connector)
- [Get started](#get-started)
- [Enable Sign in with Apple for your app](#enable-sign-in-with-apple-for-your-app)
- [Create an identifier](#create-an-identifier)
- [Enable Sign in with Apple for your identifier](#enable-sign-in-with-apple-for-your-identifier)
- [Configure scope](#configure-scope)
- [Pitfalls of configuring scope](#pitfalls-of-configuring-scope)
- [Test Apple connector](#test-apple-connector)
## Get started
@ -61,11 +62,23 @@ Click "Next" then "Done" to close the modal. Click "Continue" on the top-right c
>
> If you want to test locally, you need to edit `/etc/hosts` file to map localhost to a custom domain and set up a local HTTPS environment. [mkcert](https://github.com/FiloSottile/mkcert) can help you for setting up local HTTPS.
## Configure scope
To get user's email from Apple, you need to configure the scope to include `email`. For both email and name, you can use `name email` as the scope. See [Apple official docs](https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_js/incorporating_sign_in_with_apple_into_other_platforms#3332113) for more info.
> **Note**
>
> This connector doesn't support customizing `scope` (e.g., name, email) yet since Apple requires `form_post` response mode when `scope` is not empty, which is incompatible with the current connector design.
>
> We'll figure out this later.
> The user may choose to hide their email address from your app. In this case, you will not be able to retrieve the real email address. An email address like `random@privaterelay.appleid.com` will be returned instead.
### Pitfalls of configuring scope
If you have configured your app to request users' email addresses after they have already signed in with Apple, you will not be able to retrieve the email addresses for those existing users, even if they sign in again using Apple ID. To address this, you need to instruct your users to visit the [Apple ID account management page](https://appleid.apple.com/account/manage) and remove your application from the "Sign in with Apple" section. This can be done by selecting "Stop using Apple Sign In" on your app's detail page.
For instance, if your app requests both the users' email and name (`email name` scope), the consent page that new users see during their first sign-in should look similar to this:
![Sign in with Apple consent page](/packages/connectors/connector-apple/docs/sign-in-with-apple-consent-page.png)
See developer discussion [here](https://forums.developer.apple.com/forums/thread/132223).
## Test Apple connector

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

View file

@ -7,9 +7,6 @@ export const authorizationEndpoint = `${issuer}/auth/authorize`;
export const accessTokenEndpoint = `${issuer}/auth/token`;
export const jwksUri = `${issuer}/auth/keys`;
// Note: only support fixed scope for v1.
export const scope = ''; // Note: `openid` is required when adding more scope(s)
export const defaultMetadata: ConnectorMetadata = {
id: 'apple-universal',
target: 'apple',
@ -34,8 +31,15 @@ export const defaultMetadata: ConnectorMetadata = {
key: 'clientId',
type: ConnectorConfigFormItemType.Text,
required: true,
label: 'Client ID',
placeholder: '<client-id>',
label: 'Identifier',
placeholder: '<your-registered-identifier>',
},
{
key: 'scope',
type: ConnectorConfigFormItemType.Text,
required: false,
label: 'Scope',
placeholder: 'email name',
},
],
};

View file

@ -42,8 +42,8 @@ describe('getAuthorizationUri', () => {
expect(searchParams.get('redirect_uri')).toEqual('http://localhost:3000/callback');
expect(searchParams.get('state')).toEqual('some_state');
expect(searchParams.get('response_type')).toEqual('code id_token');
expect(searchParams.get('response_mode')).toEqual('fragment');
expect(searchParams.has('scope')).toBeTruthy();
expect(searchParams.get('response_mode')).toEqual('form_post');
expect(searchParams.get('scope')).toEqual('scope');
expect(searchParams.has('nonce')).toBeTruthy();
});
});
@ -55,11 +55,41 @@ describe('getUserInfo', () => {
it('should get user info from id token payload', async () => {
const userId = 'userId';
const mockJwtVerify = jwtVerify;
mockJwtVerify.mockImplementationOnce(() => ({ payload: { sub: userId } }));
jwtVerify.mockImplementationOnce(() => ({
payload: { sub: userId, email: 'foo@bar.com', email_verified: true },
}));
const connector = await createConnector({ getConfig });
const userInfo = await connector.getUserInfo({ id_token: 'idToken' }, jest.fn());
expect(userInfo).toEqual({ id: userId });
expect(userInfo).toEqual({ id: userId, email: 'foo@bar.com' });
});
it('should ignore unverified email', async () => {
jwtVerify.mockImplementationOnce(() => ({
payload: { sub: 'userId', email: 'foo@bar.com' },
}));
const connector = await createConnector({ getConfig });
const userInfo = await connector.getUserInfo({ id_token: 'idToken' }, jest.fn());
expect(userInfo).toEqual({ id: 'userId' });
});
it('should get user info from the `user` field', async () => {
const userId = 'userId';
const connector = await createConnector({ getConfig });
jwtVerify.mockImplementationOnce(() => ({
payload: { sub: userId, email: 'foo@bar.com', email_verified: true },
}));
const userInfo = await connector.getUserInfo(
{
id_token: 'idToken',
user: JSON.stringify({
email: 'foo2@bar.com',
name: { firstName: 'foo', lastName: 'bar' },
}),
},
jest.fn()
);
// Should use info from `user` field first
expect(userInfo).toEqual({ id: userId, email: 'foo2@bar.com', name: 'foo bar' });
});
it('should throw if id token is missing', async () => {
@ -70,8 +100,7 @@ describe('getUserInfo', () => {
});
it('should throw if verify id token failed', async () => {
const mockJwtVerify = jwtVerify;
mockJwtVerify.mockImplementationOnce(() => {
jwtVerify.mockImplementationOnce(() => {
throw new Error('jwtVerify failed');
});
const connector = await createConnector({ getConfig });
@ -81,8 +110,7 @@ describe('getUserInfo', () => {
});
it('should throw if the id token payload does not contains sub', async () => {
const mockJwtVerify = jwtVerify;
mockJwtVerify.mockImplementationOnce(() => ({
jwtVerify.mockImplementationOnce(() => ({
payload: { iat: 123_456 },
}));
const connector = await createConnector({ getConfig });

View file

@ -16,7 +16,7 @@ import {
import { generateStandardId } from '@logto/shared/universal';
import { createRemoteJWKSet, jwtVerify } from 'jose';
import { scope, defaultMetadata, jwksUri, issuer, authorizationEndpoint } from './constant.js';
import { defaultMetadata, jwksUri, issuer, authorizationEndpoint } from './constant.js';
import { appleConfigGuard, dataGuard } from './types.js';
const generateNonce = () => generateStandardId();
@ -33,12 +33,12 @@ const getAuthorizationUri =
const queryParameters = new URLSearchParams({
client_id: config.clientId,
redirect_uri: redirectUri,
scope,
scope: config.scope ?? '',
state,
nonce,
// https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_js/incorporating_sign_in_with_apple_into_other_platforms#3332113
response_type: 'code id_token',
response_mode: 'fragment',
response_mode: 'form_post',
});
assert(
@ -55,7 +55,7 @@ const getAuthorizationUri =
const getUserInfo =
(getConfig: GetConnectorConfig): GetUserInfo =>
async (data, getSession) => {
const { id_token: idToken } = await authorizationCallbackHandler(data);
const { id_token: idToken, user } = await authorizationCallbackHandler(data);
if (!idToken) {
throw new ConnectorError(ConnectorErrorCodes.SocialIdTokenInvalid);
@ -103,6 +103,15 @@ const getUserInfo =
return {
id: payload.sub,
// The `user` object is only available at the first sign-in. Didn't find this in Apple's
// docs but it seems to be the case. Fallback to the `email` field in the ID token just in
// case.
// See desperate developer discussion here:
// https://forums.developer.apple.com/forums/thread/132223
email:
user?.email ??
(payload.email && payload.email_verified === true ? String(payload.email) : undefined),
name: [user?.name?.firstName, user?.name?.lastName].filter(Boolean).join(' ') || undefined,
};
} catch {
throw new ConnectorError(ConnectorErrorCodes.SocialIdTokenInvalid);

View file

@ -1,4 +1,4 @@
export const mockedConfig = {
clientId: '<client-id>',
clientSecret: '<client-secret>',
scope: 'scope',
};

View file

@ -2,11 +2,39 @@ import { z } from 'zod';
export const appleConfigGuard = z.object({
clientId: z.string(),
scope: z.string().optional(),
});
export type AppleConfig = z.infer<typeof appleConfigGuard>;
const stringToJson = () =>
z.string().transform((value, ctx): z.ZodType<JSON> => {
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return JSON.parse(value);
} catch {
ctx.addIssue({ code: 'custom', message: 'Invalid JSON' });
return z.NEVER;
}
});
// https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_js/configuring_your_webpage_for_sign_in_with_apple#3331292
// https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_js/incorporating_sign_in_with_apple_into_other_platforms#3332113
export const dataGuard = z.object({
id_token: z.string(),
user: stringToJson()
.pipe(
z
.object({
name: z
.object({
firstName: z.string(),
lastName: z.string(),
})
.partial(),
email: z.string(),
})
.partial()
)
.optional(),
});

View file

@ -38,7 +38,7 @@ export default function koaSpaProxy<StateT, ContextT extends IRouterParamContext
return async (ctx, next) => {
const requestPath = ctx.request.path;
// Route has been handled by one of mounted apps
// Skip if the request is for another app
if (!prefix && mountedApps.some((app) => app !== prefix && requestPath.startsWith(`/${app}`))) {
return next();
}

View file

@ -0,0 +1,19 @@
import Koa from 'koa';
import supertest from 'supertest';
import { mountCallbackRouter } from './callback.js';
describe('social connector form post callback', () => {
const app = new Koa();
mountCallbackRouter(app);
const request = supertest(app.callback());
it('should redirect to the same path with query string', async () => {
const response = await request.post('/callback/some_connector_id').send({ some: 'data' });
expect(response.status).toBe(303);
expect(response.header.location).toBe('/callback/some_connector_id?some=data');
});
// No counter-case here since `koa-body` has a high tolerance for invalid requests
});

View file

@ -0,0 +1,31 @@
/**
* @fileoverview This file is used to configure routes handle the callback via form submission
* (POST request) from the authentication provider.
*/
import type Koa from 'koa';
import koaBody from 'koa-body';
import Router from 'koa-router';
import RequestError from '#src/errors/RequestError/index.js';
import assertThat from '#src/utils/assert-that.js';
function callbackRoutes<T extends Router>(router: T) {
router.post('/callback/:connectorId', koaBody(), async (ctx) => {
assertThat(
typeof ctx.request.body === 'object' && ctx.request.body !== null,
new RequestError('oidc.invalid_request')
);
ctx.status = 303;
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
ctx.set('Location', ctx.request.path + '?' + new URLSearchParams(ctx.request.body).toString());
});
}
export const mountCallbackRouter = (app: Koa) => {
const router = new Router();
callbackRoutes(router);
app.use(router.routes());
};

View file

@ -24,6 +24,7 @@ import koaSlonikErrorHandler from '#src/middleware/koa-slonik-error-handler.js';
import koaSpaProxy from '#src/middleware/koa-spa-proxy.js';
import koaSpaSessionGuard from '#src/middleware/koa-spa-session-guard.js';
import initOidc from '#src/oidc/init.js';
import { mountCallbackRouter } from '#src/routes/callback.js';
import { routes } from '#src/routes/consts.js';
import initApis from '#src/routes/init.js';
import initMeApis from '#src/routes-me/init.js';
@ -101,6 +102,9 @@ export default class Tenant implements TenantContext {
invalidateCache: this.invalidateCache.bind(this),
};
// Sign-in experience callback via form submission
mountCallbackRouter(app);
// Mount APIs
app.use(mount('/api', initApis(tenantContext)));

View file

@ -0,0 +1,19 @@
import { got } from 'got';
import { logtoConsoleUrl } from '#src/constants.js';
describe('social connector form post callback', () => {
const request = got.extend({
prefixUrl: new URL(logtoConsoleUrl),
});
it('should redirect to the same path with query string', async () => {
const response = await request.post('callback/some_connector_id', {
json: { some: 'data' },
followRedirect: false,
});
expect(response.statusCode).toBe(303);
expect(response.headers.location).toBe('/callback/some_connector_id?some=data');
});
});