Skip to content

Commit 752d406

Browse files
authored
fix(connector): support string typed boolean ID token claims for OIDC connector (#7276)
1 parent 9c51626 commit 752d406

File tree

5 files changed

+163
-7
lines changed

5 files changed

+163
-7
lines changed

.changeset/selfish-zoos-worry.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@logto/connector-oidc": patch
3+
---
4+
5+
support string-typed boolean claims
6+
7+
Add an optional `acceptStringTypedBooleanClaims` configuration to `OidcConnectorConfig`, with default value `false`.
8+
For standard OIDC protocol, some claims such as `email_verified` and `phone_verified` are boolean-typed, but some providers may return them as string-typed. Enabling this option will convert string-typed boolean claims to boolean-typed, which provides better compatibility.
9+
By enabling this configuration, the connector will accept string-typed boolean ID token claims, such as `email_verified` and `phone_verified`.

packages/connectors/connector-oidc/src/constant.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,15 @@ export const defaultMetadata: ConnectorMetadata = {
3535
...scopeFormItem,
3636
required: true,
3737
},
38+
{
39+
key: 'acceptStringTypedBooleanClaims',
40+
label: 'Accept String-typed Boolean Claims',
41+
description:
42+
'Whether to accept string-typed boolean claims. For standard OIDC protocol, some claims such as `email_verified` and `phone_verified` are boolean-typed, but some providers may return them as string-typed. Enabling this option will convert string-typed boolean claims to boolean-typed.',
43+
type: ConnectorConfigFormItemType.Switch,
44+
required: false,
45+
defaultValue: false,
46+
},
3847
{
3948
key: 'idTokenVerificationConfig',
4049
label: 'ID Token Verification Config',

packages/connectors/connector-oidc/src/index.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,11 @@ import { createRemoteJWKSet, jwtVerify } from 'jose';
2020
import { HTTPError } from 'ky';
2121

2222
import { defaultMetadata } from './constant.js';
23-
import { idTokenProfileStandardClaimsGuard, oidcConnectorConfigGuard } from './types.js';
23+
import {
24+
idTokenProfileStandardClaimsGuard,
25+
idTokenClaimsGuardWithStringBooleans,
26+
oidcConnectorConfigGuard,
27+
} from './types.js';
2428
import { getIdToken } from './utils.js';
2529

2630
const generateNonce = () => generateStandardId();
@@ -103,7 +107,9 @@ const getUserInfo =
103107
}
104108
);
105109

106-
const result = idTokenProfileStandardClaimsGuard.safeParse(payload);
110+
const result = parsedConfig.acceptStringTypedBooleanClaims
111+
? idTokenClaimsGuardWithStringBooleans.safeParse(payload)
112+
: idTokenProfileStandardClaimsGuard.safeParse(payload);
107113

108114
if (!result.success) {
109115
throw new ConnectorError(ConnectorErrorCodes.SocialIdTokenInvalid, result.error);

packages/connectors/connector-oidc/src/types.test.ts

Lines changed: 109 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { scopePostProcessor } from './types.js';
1+
import { scopePostProcessor, idTokenClaimsGuardWithStringBooleans } from './types.js';
22

33
describe('scopePostProcessor', () => {
44
it('`openid` will be added if not exists (with empty string)', () => {
@@ -13,3 +13,111 @@ describe('scopePostProcessor', () => {
1313
expect(scopePostProcessor('profile openid')).toEqual('profile openid');
1414
});
1515
});
16+
17+
describe('idTokenClaimsGuardWithStringBooleans', () => {
18+
it('should accept boolean values for email_verified and phone_verified', () => {
19+
const result = idTokenClaimsGuardWithStringBooleans.parse({
20+
sub: 'subject',
21+
email_verified: true,
22+
phone_verified: false,
23+
});
24+
25+
expect(result).toEqual({
26+
sub: 'subject',
27+
email_verified: true,
28+
phone_verified: false,
29+
});
30+
});
31+
32+
it('should accept null values for email_verified and phone_verified', () => {
33+
const result = idTokenClaimsGuardWithStringBooleans.parse({
34+
sub: 'subject',
35+
email_verified: null,
36+
phone_verified: null,
37+
});
38+
39+
expect(result).toEqual({
40+
sub: 'subject',
41+
email_verified: null,
42+
phone_verified: null,
43+
});
44+
});
45+
46+
it('should transform string "true" to boolean true for email_verified and phone_verified', () => {
47+
const result = idTokenClaimsGuardWithStringBooleans.parse({
48+
sub: 'subject',
49+
email_verified: 'true',
50+
phone_verified: 'TRUE',
51+
});
52+
53+
expect(result).toEqual({
54+
sub: 'subject',
55+
email_verified: true,
56+
phone_verified: true,
57+
});
58+
});
59+
60+
it('should transform string "false" to boolean false for email_verified and phone_verified', () => {
61+
const result = idTokenClaimsGuardWithStringBooleans.parse({
62+
sub: 'subject',
63+
email_verified: 'false',
64+
phone_verified: 'FALSE',
65+
});
66+
67+
expect(result).toEqual({
68+
sub: 'subject',
69+
email_verified: false,
70+
phone_verified: false,
71+
});
72+
});
73+
74+
it('should transform string "0" to boolean false for email_verified and phone_verified', () => {
75+
const result = idTokenClaimsGuardWithStringBooleans.parse({
76+
sub: 'subject',
77+
email_verified: '0',
78+
phone_verified: '0',
79+
});
80+
81+
expect(result).toEqual({
82+
sub: 'subject',
83+
email_verified: false,
84+
phone_verified: false,
85+
});
86+
});
87+
88+
it('should transform string "1" to boolean true for email_verified and phone_verified', () => {
89+
const result = idTokenClaimsGuardWithStringBooleans.parse({
90+
sub: 'subject',
91+
email_verified: '1',
92+
phone_verified: '1',
93+
});
94+
95+
expect(result).toEqual({
96+
sub: 'subject',
97+
email_verified: true,
98+
phone_verified: true,
99+
});
100+
});
101+
102+
it('should accept other standard claims', () => {
103+
const result = idTokenClaimsGuardWithStringBooleans.parse({
104+
sub: 'subject',
105+
name: 'John Doe',
106+
107+
phone: '+1234567890',
108+
picture: 'https://example.com/avatar.jpg',
109+
profile: 'https://example.com/profile',
110+
nonce: 'random-nonce',
111+
});
112+
113+
expect(result).toEqual({
114+
sub: 'subject',
115+
name: 'John Doe',
116+
117+
phone: '+1234567890',
118+
picture: 'https://example.com/avatar.jpg',
119+
profile: 'https://example.com/profile',
120+
nonce: 'random-nonce',
121+
});
122+
});
123+
});

packages/connectors/connector-oidc/src/types.ts

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
1+
import { yes } from '@silverhand/essentials';
12
import { z } from 'zod';
23

34
import { oauth2ConfigGuard } from '@logto/connector-oauth';
45

56
const scopeOpenid = 'openid';
67
export const delimiter = /[ +]/;
78

8-
// Space-delimited 'scope' MUST contain 'openid', see https://openid.net/specs/openid-connect-core-1_0.html#CodeFlowAuth
9+
/**
10+
* Space-delimited 'scope' MUST contain 'openid', see https://openid.net/specs/openid-connect-core-1_0.html#CodeFlowAuth
11+
*/
912
export const scopePostProcessor = (scope: string) => {
1013
const splitScopes = scope.split(delimiter).filter(Boolean);
1114

@@ -16,8 +19,10 @@ export const scopePostProcessor = (scope: string) => {
1619
return scope;
1720
};
1821

19-
// See https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims.
20-
// We only concern a subset of them, and social identity provider usually does not provide a complete set of them.
22+
/**
23+
* See https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims.
24+
* We only concern a subset of them, and social identity provider usually does not provide a complete set of them.
25+
*/
2126
export const idTokenProfileStandardClaimsGuard = z.object({
2227
sub: z.string(),
2328
name: z.string().nullish(),
@@ -30,6 +35,22 @@ export const idTokenProfileStandardClaimsGuard = z.object({
3035
nonce: z.string().nullish(),
3136
});
3237

38+
/**
39+
* Extend `idTokenProfileStandardClaimsGuard` by accepting string-typed boolean claims.
40+
*/
41+
export const idTokenClaimsGuardWithStringBooleans = idTokenProfileStandardClaimsGuard
42+
.omit({ email_verified: true, phone_verified: true })
43+
.extend({
44+
email_verified: z
45+
.boolean()
46+
.or(z.string().transform((value: string) => yes(value)))
47+
.nullish(),
48+
phone_verified: z
49+
.boolean()
50+
.or(z.string().transform((value: string) => yes(value)))
51+
.nullish(),
52+
});
53+
3354
export const userProfileGuard = z.object({
3455
id: z.preprocess(String, z.string()),
3556
email: z.string().optional(),
@@ -57,7 +78,9 @@ export const authRequestOptionalConfigGuard = z
5778
})
5879
.partial();
5980

60-
// See https://github.com/panva/jose/blob/main/docs/interfaces/jwt_verify.JWTVerifyOptions.md for details.
81+
/**
82+
* See https://github.com/panva/jose/blob/main/docs/interfaces/jwt_verify.JWTVerifyOptions.md for details.
83+
*/
6184
export const idTokenVerificationConfigGuard = z.object({ jwksUri: z.string() }).merge(
6285
z
6386
.object({
@@ -79,6 +102,7 @@ export type IdTokenVerificationConfig = z.infer<typeof idTokenVerificationConfig
79102
export const oidcConnectorConfigGuard = oauth2ConfigGuard.extend({
80103
// Override `scope` to ensure it contains 'openid'.
81104
scope: z.string().transform(scopePostProcessor),
105+
acceptStringTypedBooleanClaims: z.boolean().optional().default(false),
82106
idTokenVerificationConfig: idTokenVerificationConfigGuard,
83107
authRequestOptionalConfig: authRequestOptionalConfigGuard.optional(),
84108
customConfig: z.record(z.string()).optional(),

0 commit comments

Comments
 (0)