diff --git a/.changeset/eight-pans-wait.md b/.changeset/eight-pans-wait.md new file mode 100644 index 000000000..10fefa00f --- /dev/null +++ b/.changeset/eight-pans-wait.md @@ -0,0 +1,5 @@ +--- +"@logto/connector-apple": minor +--- + +support `scope` diff --git a/.changeset/proud-birds-push.md b/.changeset/proud-birds-push.md new file mode 100644 index 000000000..b10eadd87 --- /dev/null +++ b/.changeset/proud-birds-push.md @@ -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. diff --git a/packages/connectors/connector-apple/README.md b/packages/connectors/connector-apple/README.md index d74f5224d..ba1e53245 100644 --- a/packages/connectors/connector-apple/README.md +++ b/packages/connectors/connector-apple/README.md @@ -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 diff --git a/packages/connectors/connector-apple/docs/sign-in-with-apple-consent-page.png b/packages/connectors/connector-apple/docs/sign-in-with-apple-consent-page.png new file mode 100644 index 000000000..b136b8def Binary files /dev/null and b/packages/connectors/connector-apple/docs/sign-in-with-apple-consent-page.png differ diff --git a/packages/connectors/connector-apple/src/constant.ts b/packages/connectors/connector-apple/src/constant.ts index 5ac37350c..8e8fc6d11 100644 --- a/packages/connectors/connector-apple/src/constant.ts +++ b/packages/connectors/connector-apple/src/constant.ts @@ -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: '', + label: 'Identifier', + placeholder: '', + }, + { + key: 'scope', + type: ConnectorConfigFormItemType.Text, + required: false, + label: 'Scope', + placeholder: 'email name', }, ], }; diff --git a/packages/connectors/connector-apple/src/index.test.ts b/packages/connectors/connector-apple/src/index.test.ts index 567cecf15..fdf8c7771 100644 --- a/packages/connectors/connector-apple/src/index.test.ts +++ b/packages/connectors/connector-apple/src/index.test.ts @@ -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 }); diff --git a/packages/connectors/connector-apple/src/index.ts b/packages/connectors/connector-apple/src/index.ts index a65b242f9..fa699aab4 100644 --- a/packages/connectors/connector-apple/src/index.ts +++ b/packages/connectors/connector-apple/src/index.ts @@ -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); diff --git a/packages/connectors/connector-apple/src/mock.ts b/packages/connectors/connector-apple/src/mock.ts index a52d77ccc..4a7154b8d 100644 --- a/packages/connectors/connector-apple/src/mock.ts +++ b/packages/connectors/connector-apple/src/mock.ts @@ -1,4 +1,4 @@ export const mockedConfig = { clientId: '', - clientSecret: '', + scope: 'scope', }; diff --git a/packages/connectors/connector-apple/src/types.ts b/packages/connectors/connector-apple/src/types.ts index 70c5175db..0c24e9b76 100644 --- a/packages/connectors/connector-apple/src/types.ts +++ b/packages/connectors/connector-apple/src/types.ts @@ -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; +const stringToJson = () => + z.string().transform((value, ctx): z.ZodType => { + 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(), }); diff --git a/packages/core/src/middleware/koa-spa-proxy.ts b/packages/core/src/middleware/koa-spa-proxy.ts index 9b99530b6..ede71a7ef 100644 --- a/packages/core/src/middleware/koa-spa-proxy.ts +++ b/packages/core/src/middleware/koa-spa-proxy.ts @@ -38,7 +38,7 @@ export default function koaSpaProxy { 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(); } diff --git a/packages/core/src/routes/callback.test.ts b/packages/core/src/routes/callback.test.ts new file mode 100644 index 000000000..4d1e55e17 --- /dev/null +++ b/packages/core/src/routes/callback.test.ts @@ -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 +}); diff --git a/packages/core/src/routes/callback.ts b/packages/core/src/routes/callback.ts new file mode 100644 index 000000000..6ab01e037 --- /dev/null +++ b/packages/core/src/routes/callback.ts @@ -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(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()); +}; diff --git a/packages/core/src/tenants/Tenant.ts b/packages/core/src/tenants/Tenant.ts index bf84fd446..4963febf4 100644 --- a/packages/core/src/tenants/Tenant.ts +++ b/packages/core/src/tenants/Tenant.ts @@ -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))); diff --git a/packages/integration-tests/src/tests/api/callback.test.ts b/packages/integration-tests/src/tests/api/callback.test.ts new file mode 100644 index 000000000..3a3f34417 --- /dev/null +++ b/packages/integration-tests/src/tests/api/callback.test.ts @@ -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'); + }); +});