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:
parent
0457df0e35
commit
532454b923
14 changed files with 196 additions and 29 deletions
5
.changeset/eight-pans-wait.md
Normal file
5
.changeset/eight-pans-wait.md
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@logto/connector-apple": minor
|
||||
---
|
||||
|
||||
support `scope`
|
7
.changeset/proud-birds-push.md
Normal file
7
.changeset/proud-birds-push.md
Normal 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.
|
|
@ -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:
|
||||
|
||||

|
||||
|
||||
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 |
|
@ -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',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
|
@ -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 });
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
export const mockedConfig = {
|
||||
clientId: '<client-id>',
|
||||
clientSecret: '<client-secret>',
|
||||
scope: 'scope',
|
||||
};
|
||||
|
|
|
@ -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(),
|
||||
});
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
19
packages/core/src/routes/callback.test.ts
Normal file
19
packages/core/src/routes/callback.test.ts
Normal 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
|
||||
});
|
31
packages/core/src/routes/callback.ts
Normal file
31
packages/core/src/routes/callback.ts
Normal 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());
|
||||
};
|
|
@ -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)));
|
||||
|
||||
|
|
19
packages/integration-tests/src/tests/api/callback.test.ts
Normal file
19
packages/integration-tests/src/tests/api/callback.test.ts
Normal 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');
|
||||
});
|
||||
});
|
Loading…
Add table
Reference in a new issue