diff --git a/docs/components/sidebar-content.tsx b/docs/components/sidebar-content.tsx index fa8e56235a..843db28050 100644 --- a/docs/components/sidebar-content.tsx +++ b/docs/components/sidebar-content.tsx @@ -510,6 +510,25 @@ export const contents: Content[] = [ ), }, + { + title: "Cognito", + href: "/docs/authentication/cognito", + isNew: true, + icon: () => ( + + + + + ), + }, { title: "Discord", href: "/docs/authentication/discord", diff --git a/docs/content/docs/authentication/cognito.mdx b/docs/content/docs/authentication/cognito.mdx new file mode 100644 index 0000000000..46577f8c7a --- /dev/null +++ b/docs/content/docs/authentication/cognito.mdx @@ -0,0 +1,78 @@ +--- +title: Cognito +description: Amazon Cognito provider setup and usage. +--- + + + + ### Get your Cognito Credentials + To integrate with Cognito, you need to set up a **User Pool** and an **App client** in the [Amazon Cognito Console](https://console.aws.amazon.com/cognito/). + + Follow these steps: + 1. Go to the **Cognito Console** and create a **User Pool**. + 2. Under **App clients**, create a new **App client** (note the Client ID and Client Secret if enabled). + 3. Go to **Domain** and set a Cognito Hosted UI domain (e.g., `your-app.auth.us-east-1.amazoncognito.com`). + 4. In **App client settings**, enable: + - Allowed OAuth flows: `Authorization code grant` + - Allowed OAuth scopes: `openid`, `profile`, `email` + 5. Add your callback URL (e.g., `http://localhost:3000/api/auth/callback/cognito`). + + + - **User Pool is required** for Cognito authentication. + - Make sure the callback URL matches exactly what you configure in Cognito. + + + + + ### Configure the provider + Import the Cognito provider and pass it to the `providers` option of your `auth` instance. + + ```ts title="auth.ts" + import { betterAuth } from "better-auth" + + export const auth = betterAuth({ + socialProvider: { + cognito({ + clientId: process.env.COGNITO_CLIENT_ID as string, // [!code highlight] + clientSecret: process.env.COGNITO_CLIENT_SECRET as string, // [!code highlight] + domain: process.env.COGNITO_DOMAIN as string, // e.g. "your-app.auth.us-east-1.amazoncognito.com" [!code highlight] + region: process.env.COGNITO_REGION as string, // e.g. "us-east-1" [!code highlight] + userPoolId: process.env.COGNITO_USERPOOL_ID as string, // [!code highlight] + }), + }, + }) + ``` + + + + ### Sign In with Cognito + To sign in with Cognito, use the `signIn.social` function from the client. + + ```ts title="auth-client.ts" + import { createAuthClient } from "better-auth/client" + + const authClient = createAuthClient() + + const signIn = async () => { + const data = await authClient.signIn.social({ + provider: "cognito" + }) + } + ``` + + ### Additional Options: + - `scope`: Additional OAuth2 scopes to request (combined with default permissions). + - Default: `"openid" "profile" "email"` + - Common Cognito scopes: + - `openid`: Required for OpenID Connect authentication + - `profile`: Access to basic profile info + - `email`: Access to user’s email + - `phone`: Access to user’s phone number + - `aws.cognito.signin.user.admin`: Grants access to Cognito-specific APIs + - Note: You must configure the scopes in your Cognito App Client settings. [available scopes](https://docs.aws.amazon.com/cognito/latest/developerguide/token-endpoint.html#token-endpoint-userinfo) + - `getUserInfo`: Custom function to retrieve user information from the Cognito UserInfo endpoint. + + For more information about Amazon Cognito's scopes and API capabilities, refer to the [official documentation](https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-pools-define-resource-servers.html?utm_source). + + + diff --git a/packages/better-auth/src/social-providers/cognito.ts b/packages/better-auth/src/social-providers/cognito.ts new file mode 100644 index 0000000000..1292e341a0 --- /dev/null +++ b/packages/better-auth/src/social-providers/cognito.ts @@ -0,0 +1,265 @@ +import { betterFetch } from "@better-fetch/fetch"; +import { decodeJwt, decodeProtectedHeader, importJWK, jwtVerify } from "jose"; +import { BetterAuthError } from "../error"; +import type { OAuthProvider, ProviderOptions } from "../oauth2"; +import { createAuthorizationURL, validateAuthorizationCode } from "../oauth2"; +import { logger } from "../utils/logger"; +import { refreshAccessToken } from "../oauth2/refresh-access-token"; +import { APIError } from "better-call"; + +export interface CognitoProfile { + sub: string; + email: string; + email_verified: boolean; + name: string; + given_name?: string; + family_name?: string; + picture?: string; + username?: string; + locale?: string; + phone_number?: string; + phone_number_verified?: boolean; + aud: string; + iss: string; + exp: number; + iat: number; + // Custom attributes from Cognito can be added here + [key: string]: any; +} + +export interface CognitoOptions extends ProviderOptions { + /** + * The Cognito domain (e.g., "your-app.auth.us-east-1.amazoncognito.com") + */ + domain: string; + /** + * AWS region where User Pool is hosted (e.g., "us-east-1") + */ + region: string; + userPoolId: string; + requireClientSecret?: boolean; +} + +export const cognito = (options: CognitoOptions) => { + if (!options.domain || !options.region || !options.userPoolId) { + logger.error( + "Domain, region and userPoolId are required for Amazon Cognito. Make sure to provide them in the options.", + ); + throw new BetterAuthError("DOMAIN_AND_REGION_REQUIRED"); + } + + const cleanDomain = options.domain.replace(/^https?:\/\//, ""); + const authorizationEndpoint = `https://${cleanDomain}/oauth2/authorize`; + const tokenEndpoint = `https://${cleanDomain}/oauth2/token`; + const userInfoEndpoint = `https://${cleanDomain}/oauth2/userinfo`; + + return { + id: "cognito", + name: "Cognito", + async createAuthorizationURL({ state, scopes, codeVerifier, redirectURI }) { + if (!options.clientId) { + logger.error( + "ClientId is required for Amazon Cognito. Make sure to provide them in the options.", + ); + throw new BetterAuthError("CLIENT_ID_AND_SECRET_REQUIRED"); + } + + if (options.requireClientSecret && !options.clientSecret) { + logger.error( + "Client Secret is required when requireClientSecret is true. Make sure to provide it in the options.", + ); + throw new BetterAuthError("CLIENT_SECRET_REQUIRED"); + } + const _scopes = options.disableDefaultScope + ? [] + : ["openid", "profile", "email"]; + options.scope && _scopes.push(...options.scope); + scopes && _scopes.push(...scopes); + + const url = await createAuthorizationURL({ + id: "cognito", + options: { + ...options, + }, + authorizationEndpoint, + scopes: _scopes, + state, + codeVerifier, + redirectURI, + prompt: options.prompt, + }); + return url; + }, + + validateAuthorizationCode: async ({ code, codeVerifier, redirectURI }) => { + return validateAuthorizationCode({ + code, + codeVerifier, + redirectURI, + options, + tokenEndpoint, + }); + }, + + refreshAccessToken: options.refreshAccessToken + ? options.refreshAccessToken + : async (refreshToken) => { + return refreshAccessToken({ + refreshToken, + options: { + clientId: options.clientId, + clientKey: options.clientKey, + clientSecret: options.clientSecret, + }, + tokenEndpoint, + }); + }, + + async verifyIdToken(token, nonce) { + if (options.disableIdTokenSignIn) { + return false; + } + if (options.verifyIdToken) { + return options.verifyIdToken(token, nonce); + } + + try { + const decodedHeader = decodeProtectedHeader(token); + const { kid, alg: jwtAlg } = decodedHeader; + if (!kid || !jwtAlg) return false; + + const publicKey = await getCognitoPublicKey( + kid, + options.region, + options.userPoolId, + ); + const expectedIssuer = `https://cognito-idp.${options.region}.amazonaws.com/${options.userPoolId}`; + + const { payload: jwtClaims } = await jwtVerify(token, publicKey, { + algorithms: [jwtAlg], + issuer: expectedIssuer, + audience: options.clientId, + maxTokenAge: "1h", + }); + + if (nonce && jwtClaims.nonce !== nonce) { + return false; + } + return true; + } catch (error) { + logger.error("Failed to verify ID token:", error); + return false; + } + }, + + async getUserInfo(token) { + if (options.getUserInfo) { + return options.getUserInfo(token); + } + + if (token.idToken) { + try { + const profile = decodeJwt(token.idToken); + if (!profile) { + return null; + } + const name = + profile.name || + profile.given_name || + profile.username || + profile.email; + const enrichedProfile = { + ...profile, + name, + }; + const userMap = await options.mapProfileToUser?.(enrichedProfile); + + return { + user: { + id: profile.sub, + name: enrichedProfile.name, + email: profile.email, + image: profile.picture, + emailVerified: profile.email_verified, + ...userMap, + }, + data: enrichedProfile, + }; + } catch (error) { + logger.error("Failed to decode ID token:", error); + } + } + + if (token.accessToken) { + try { + const { data: userInfo } = await betterFetch( + userInfoEndpoint, + { + headers: { + Authorization: `Bearer ${token.accessToken}`, + }, + }, + ); + + if (userInfo) { + const userMap = await options.mapProfileToUser?.(userInfo); + return { + user: { + id: userInfo.sub, + name: userInfo.name || userInfo.given_name || userInfo.username, + email: userInfo.email, + image: userInfo.picture, + emailVerified: userInfo.email_verified, + ...userMap, + }, + data: userInfo, + }; + } + } catch (error) { + logger.error("Failed to fetch user info from Cognito:", error); + } + } + + return null; + }, + + options, + } satisfies OAuthProvider; +}; + +export const getCognitoPublicKey = async ( + kid: string, + region: string, + userPoolId: string, +) => { + const COGNITO_JWKS_URI = `https://cognito-idp.${region}.amazonaws.com/${userPoolId}/.well-known/jwks.json`; + + try { + const { data } = await betterFetch<{ + keys: Array<{ + kid: string; + alg: string; + kty: string; + use: string; + n: string; + e: string; + }>; + }>(COGNITO_JWKS_URI); + + if (!data?.keys) { + throw new APIError("BAD_REQUEST", { + message: "Keys not found", + }); + } + + const jwk = data.keys.find((key) => key.kid === kid); + if (!jwk) { + throw new Error(`JWK with kid ${kid} not found`); + } + + return await importJWK(jwk, jwk.alg); + } catch (error) { + logger.error("Failed to fetch Cognito public key:", error); + throw error; + } +}; diff --git a/packages/better-auth/src/social-providers/index.ts b/packages/better-auth/src/social-providers/index.ts index 88898adbf0..e842985d9b 100644 --- a/packages/better-auth/src/social-providers/index.ts +++ b/packages/better-auth/src/social-providers/index.ts @@ -2,6 +2,7 @@ import * as z from "zod/v4"; import type { Prettify } from "../types/helper"; import { apple } from "./apple"; import { atlassian } from "./atlassian"; +import { cognito } from "./cognito"; import { discord } from "./discord"; import { facebook } from "./facebook"; import { figma } from "./figma"; @@ -33,6 +34,7 @@ import { paypal } from "./paypal"; export const socialProviders = { apple, atlassian, + cognito, discord, facebook, figma, @@ -83,6 +85,7 @@ export type SocialProviders = { export * from "./apple"; export * from "./atlassian"; +export * from "./cognito"; export * from "./discord"; export * from "./dropbox"; export * from "./facebook";