Skip to content

Commit ae43264

Browse files
committed
feat(core): add Google One Tap api
1 parent e688bbc commit ae43264

File tree

2 files changed

+229
-49
lines changed

2 files changed

+229
-49
lines changed

packages/core/src/routes/google-one-tap/index.openapi.json

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,71 @@
4848
}
4949
}
5050
}
51+
},
52+
"/api/google-one-tap/verify": {
53+
"post": {
54+
"summary": "Verify Google One Tap ID Token and generate magic link",
55+
"description": "Verify the Google One Tap ID Token, check if the user exists, and generate a magic link for authentication. If the user exists, generates a login magic link; otherwise, generates a registration magic link.",
56+
"requestBody": {
57+
"content": {
58+
"application/json": {
59+
"schema": {
60+
"type": "object",
61+
"properties": {
62+
"idToken": {
63+
"type": "string",
64+
"description": "The Google ID Token from Google One Tap"
65+
},
66+
"redirectUri": {
67+
"type": "string",
68+
"format": "uri",
69+
"description": "Optional redirect URI for the magic link. If not provided, uses the request origin."
70+
}
71+
},
72+
"required": ["idToken"]
73+
}
74+
}
75+
}
76+
},
77+
"responses": {
78+
"200": {
79+
"description": "The ID Token was verified successfully and magic link was generated.",
80+
"content": {
81+
"application/json": {
82+
"schema": {
83+
"type": "object",
84+
"properties": {
85+
"magicLink": {
86+
"type": "string",
87+
"format": "uri",
88+
"description": "The generated magic link URL for authentication"
89+
},
90+
"isNewUser": {
91+
"type": "boolean",
92+
"description": "Whether this is a new user (registration) or existing user (login)"
93+
},
94+
"email": {
95+
"type": "string",
96+
"format": "email",
97+
"description": "The verified email address from the Google ID Token"
98+
}
99+
},
100+
"required": ["magicLink", "isNewUser", "email"]
101+
}
102+
}
103+
}
104+
},
105+
"400": {
106+
"description": "Invalid ID Token, unverified email, or other validation errors"
107+
},
108+
"403": {
109+
"description": "Access forbidden, either due to CORS restrictions or feature not enabled"
110+
},
111+
"404": {
112+
"description": "Google connector not found"
113+
}
114+
}
115+
}
51116
}
52117
}
53118
}

packages/core/src/routes/google-one-tap/index.ts

Lines changed: 164 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,112 @@
11
import { GoogleConnector } from '@logto/connector-kit';
22
import { ConnectorType } from '@logto/schemas';
3-
import { ConsoleLog } from '@logto/shared';
3+
import { ConsoleLog, generateStandardId, generateStandardSecret } from '@logto/shared';
44
import chalk from 'chalk';
5+
import { addSeconds } from 'date-fns';
6+
import { createRemoteJWKSet, jwtVerify } from 'jose';
7+
import type { Context } from 'koa';
8+
import { z } from 'zod';
59

610
import { EnvSet } from '#src/env-set/index.js';
711
import RequestError from '#src/errors/RequestError/index.js';
812
import type TenantContext from '#src/tenants/TenantContext.js';
13+
import type { LogtoConnector } from '#src/utils/connectors/types.js';
914

1015
import koaGuard from '../../middleware/koa-guard.js';
1116
import type { AnonymousRouter } from '../types.js';
1217

1318
const consoleLog = new ConsoleLog(chalk.magenta('google-one-tap'));
1419

20+
// Default expiration time: 10 minutes.
21+
const defaultExpiresTime = 10 * 60;
22+
23+
// Google JWKS URI for token verification
24+
const googleJwksUri = 'https://www.googleapis.com/oauth2/v3/certs';
25+
26+
// List of allowed local development origins
27+
const localDevelopmentOrigins = [
28+
'localhost', // Localhost with any port
29+
'127.0.0.1', // IPv4 loopback
30+
'0.0.0.0', // All interfaces
31+
'[::1]', // IPv6 loopback
32+
'.local', // MDNS domains (especially for macOS)
33+
'host.docker.internal', // Docker host from container
34+
];
35+
36+
/**
37+
* Check CORS origin and set appropriate headers
38+
*/
39+
const setCorsHeaders = (ctx: Context, allowedMethods: string) => {
40+
const origin = ctx.get('origin');
41+
consoleLog.info(`origin: ${origin}`);
42+
43+
const { isProduction, isIntegrationTest } = EnvSet.values;
44+
45+
// Allow local development origins
46+
if (
47+
(!isProduction || isIntegrationTest) &&
48+
localDevelopmentOrigins.some((item) => origin.includes(item))
49+
) {
50+
ctx.set('Access-Control-Allow-Origin', origin);
51+
}
52+
// In production, only allow *.logto.io or *.logto.dev domains to access
53+
else if (isProduction && (origin.endsWith('.logto.io') || origin.endsWith('.logto.dev'))) {
54+
ctx.set('Access-Control-Allow-Origin', origin);
55+
} else {
56+
throw new RequestError({ code: 'auth.forbidden', status: 403 });
57+
}
58+
59+
ctx.set('Access-Control-Allow-Methods', allowedMethods);
60+
ctx.set('Access-Control-Allow-Headers', 'Content-Type');
61+
};
62+
63+
/**
64+
* Handle OPTIONS preflight requests
65+
*/
66+
const handleOptionsRequest = async (ctx: Context, next: () => Promise<void>) => {
67+
if (ctx.method === 'OPTIONS') {
68+
ctx.status = 204;
69+
return next();
70+
}
71+
};
72+
73+
/**
74+
* Get and validate Google One Tap connector configuration
75+
*/
76+
const getGoogleOneTapConnector = async (getLogtoConnectors: () => Promise<LogtoConnector[]>) => {
77+
const connectors = await getLogtoConnectors();
78+
const googleOneTapConnector = connectors.find(
79+
(connector) =>
80+
connector.type === ConnectorType.Social && connector.metadata.id === GoogleConnector.factoryId
81+
);
82+
83+
if (!googleOneTapConnector) {
84+
throw new RequestError({ code: 'connector.not_found', status: 404 });
85+
}
86+
87+
const configResult = GoogleConnector.configGuard.safeParse(googleOneTapConnector.dbEntry.config);
88+
89+
if (!configResult.success) {
90+
throw new RequestError({
91+
code: 'connector.invalid_config',
92+
status: 400,
93+
details: configResult.error.flatten(),
94+
});
95+
}
96+
97+
return { connector: googleOneTapConnector, config: configResult.data };
98+
};
99+
15100
export default function googleOneTapRoutes<T extends AnonymousRouter>(
16101
router: T,
17102
tenant: TenantContext
18103
) {
19104
const {
20105
connectors: { getLogtoConnectors },
106+
queries: {
107+
users: { findUserByIdentity },
108+
oneTimeTokens: { insertOneTimeToken, updateExpiredOneTimeTokensStatusByEmail },
109+
},
21110
} = tenant;
22111

23112
if (!EnvSet.values.isDevFeaturesEnabled) {
@@ -36,72 +125,98 @@ export default function googleOneTapRoutes<T extends AnonymousRouter>(
36125
.optional(),
37126
}),
38127
async (ctx, next) => {
39-
// Check CORS origin
40-
const origin = ctx.get('origin');
41-
consoleLog.info(`origin: ${origin}`);
128+
setCorsHeaders(ctx, 'GET, OPTIONS');
129+
130+
await handleOptionsRequest(ctx, next);
42131

43132
const { isProduction, isIntegrationTest } = EnvSet.values;
44133
consoleLog.info(`isProduction: ${isProduction}`);
45134
consoleLog.info(`isIntegrationTest: ${isIntegrationTest}`);
46135

47-
// List of allowed local development origins
48-
const localDevelopmentOrigins = [
49-
'localhost', // Localhost with any port
50-
'127.0.0.1', // IPv4 loopback
51-
'0.0.0.0', // All interfaces
52-
'[::1]', // IPv6 loopback
53-
'.local', // MDNS domains (especially for macOS)
54-
'host.docker.internal', // Docker host from container
55-
];
56-
57-
// Allow local development origins
58-
if (
59-
(!isProduction || isIntegrationTest) &&
60-
localDevelopmentOrigins.some((item) => origin.includes(item))
61-
) {
62-
ctx.set('Access-Control-Allow-Origin', origin);
63-
}
64-
// In production, only allow *.logto.io or *.logto.dev domains to access
65-
else if (isProduction && (origin.endsWith('.logto.io') || origin.endsWith('.logto.dev'))) {
66-
ctx.set('Access-Control-Allow-Origin', origin);
67-
} else {
68-
throw new RequestError({ code: 'auth.forbidden', status: 403 });
69-
}
136+
const { config } = await getGoogleOneTapConnector(getLogtoConnectors);
137+
const { clientId, oneTap } = config;
70138

71-
ctx.set('Access-Control-Allow-Methods', 'GET, OPTIONS');
72-
ctx.set('Access-Control-Allow-Headers', 'Content-Type');
139+
ctx.status = 200;
140+
ctx.body = { clientId, oneTap };
141+
return next();
142+
}
143+
);
73144

74-
if (ctx.method === 'OPTIONS') {
75-
ctx.status = 204;
76-
return next();
77-
}
145+
router.post(
146+
'/google-one-tap/verify',
147+
koaGuard({
148+
body: z.object({
149+
idToken: z.string(),
150+
redirectUri: z.string().url().optional(),
151+
}),
152+
response: z.object({
153+
magicLink: z.string().url(),
154+
isNewUser: z.boolean(),
155+
email: z.string(),
156+
}),
157+
status: [200, 204, 400, 403, 404],
158+
}),
159+
async (ctx, next) => {
160+
setCorsHeaders(ctx, 'POST, OPTIONS');
78161

79-
const connectors = await getLogtoConnectors();
80-
const googleOneTapConnector = connectors.find(
81-
(connector) =>
82-
connector.type === ConnectorType.Social &&
83-
connector.metadata.id === GoogleConnector.factoryId
84-
);
162+
await handleOptionsRequest(ctx, next);
85163

86-
if (!googleOneTapConnector) {
87-
throw new RequestError({ code: 'connector.not_found', status: 404 });
88-
}
164+
const { idToken, redirectUri } = ctx.guard.body;
165+
166+
const { config } = await getGoogleOneTapConnector(getLogtoConnectors);
167+
const { clientId } = config;
89168

90-
const result = GoogleConnector.configGuard.safeParse(googleOneTapConnector.dbEntry.config);
169+
// Verify Google ID Token
170+
const { payload } = await jwtVerify(idToken, createRemoteJWKSet(new URL(googleJwksUri)), {
171+
issuer: ['https://accounts.google.com', 'accounts.google.com'],
172+
audience: clientId,
173+
clockTolerance: 10,
174+
});
91175

92-
// This should not happen since we have already validated the config when creating and updating the connector.
93-
if (!result.success) {
176+
const { sub: googleUserId, email, email_verified } = payload;
177+
178+
if (!email || !email_verified) {
94179
throw new RequestError({
95-
code: 'connector.invalid_config',
180+
code: 'session.invalid_credentials',
96181
status: 400,
97-
details: result.error.flatten(),
98182
});
99183
}
100184

101-
const { clientId, oneTap } = result.data;
185+
// Check if user exists with this Google identity
186+
const existingUser = await findUserByIdentity(GoogleConnector.target, String(googleUserId));
187+
const isNewUser = !existingUser;
188+
189+
// Create one-time token with appropriate context
190+
const expiresAt = addSeconds(new Date(), defaultExpiresTime);
191+
const oneTimeToken = await insertOneTimeToken({
192+
id: generateStandardId(),
193+
email: String(email),
194+
token: generateStandardSecret(),
195+
expiresAt: expiresAt.getTime(),
196+
context: {
197+
// Add jitOrganizationIds if this is for registration
198+
...(isNewUser && { jitOrganizationIds: [] }),
199+
},
200+
});
201+
202+
// Clean up any expired tokens for this email
203+
await updateExpiredOneTimeTokensStatusByEmail(String(email)).catch(() => {
204+
// Ignore errors for cleanup operation
205+
});
206+
207+
// Generate magic link
208+
const baseUrl = redirectUri ?? ctx.origin;
209+
const magicLinkUrl = new URL('/auth/magic-link', baseUrl);
210+
magicLinkUrl.searchParams.set('token', oneTimeToken.token);
211+
magicLinkUrl.searchParams.set('email', String(email));
102212

103213
ctx.status = 200;
104-
ctx.body = { clientId, oneTap };
214+
ctx.body = {
215+
magicLink: magicLinkUrl.toString(),
216+
isNewUser,
217+
email: String(email),
218+
};
219+
105220
return next();
106221
}
107222
);

0 commit comments

Comments
 (0)