diff --git a/docs/content/docs/authentication/cognito.mdx b/docs/content/docs/authentication/cognito.mdx index 372c83e2e2..566c459388 100644 --- a/docs/content/docs/authentication/cognito.mdx +++ b/docs/content/docs/authentication/cognito.mdx @@ -76,6 +76,15 @@ description: Amazon Cognito provider setup and usage. * `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. + * `refreshAccessToken`: Custom function to refresh tokens. It receives the stored refresh token. + + ### Refresh tokens + + Cognito returns a refresh token after a successful authorization code grant. Later refresh-token grants return new access and ID tokens. Cognito only returns a new refresh token when refresh token rotation is enabled in the app client; otherwise, the original refresh token remains valid and Better Auth keeps using it. + + `auth.api.getAccessToken` refreshes an expired access token automatically when the provider account has a refresh token and a known `accessTokenExpiresAt`. It returns the valid access token and ID token. If you need the refresh token in the response, use the `/refresh-token` endpoint instead. + + In database-less setups, Better Auth stores provider account data, including OAuth token material, in the encrypted `account_data` cookie when `storeAccountCookie` is enabled. Token refresh responses set an updated cookie, so server-side callers must forward the returned `Set-Cookie` header to the browser. Cognito JWTs can be large; Better Auth chunks oversized account cookies, but browsers and proxies can still enforce total header limits. Use database-backed account storage for large token payloads or production flows that need durable token storage. 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/docs/content/docs/concepts/session-management.mdx b/docs/content/docs/concepts/session-management.mdx index 75aaffdbb3..81f299a460 100644 --- a/docs/content/docs/concepts/session-management.mdx +++ b/docs/content/docs/concepts/session-management.mdx @@ -391,7 +391,7 @@ export const auth = betterAuth({ }, account: { storeStateStrategy: "cookie", - storeAccountCookie: true, // Store account data after OAuth flow in a cookie (useful for database-less flows) + storeAccountCookie: true, // Store provider account data after OAuth flow in an encrypted cookie } }); ``` @@ -400,6 +400,10 @@ export const auth = betterAuth({ If you don't provide a database, by default we provide the above configuration for you. +In stateless OAuth flows, `storeAccountCookie` stores provider account data, including OAuth token material, in the encrypted `account_data` cookie. `getAccessToken` can refresh expired provider access tokens when the account cookie contains a refresh token and a known access-token expiry. Token refresh responses set an updated account cookie, so server-side integrations must forward the returned `Set-Cookie` header to the browser. + +Better Auth chunks oversized account cookies, but browsers and proxies can still enforce total cookie or header limits. Use database-backed account storage for providers that issue large JWTs or for production flows that need durable token storage. + ### Understanding `refreshCache` The `refreshCache` option controls automatic cookie refresh **before expiry** without querying any database: diff --git a/docs/content/docs/reference/options.mdx b/docs/content/docs/reference/options.mdx index 1bb2a990a5..766ee35257 100644 --- a/docs/content/docs/reference/options.mdx +++ b/docs/content/docs/reference/options.mdx @@ -350,7 +350,7 @@ export const auth = betterAuth({ * `prompt`: The prompt to use for the authorization code request (`"select_account"`, `"consent"`, `"login"`, `"none"`, `"select_account consent"`) (optional) * `responseMode`: The response mode to use (`"query"`, `"form_post"`) (optional) * `getUserInfo`: Custom function to get user info from the provider (optional) -* `refreshAccessToken`: Custom function to refresh a token (optional) +* `refreshAccessToken`: Custom function to refresh a token. Receives the stored refresh token and returns new OAuth tokens. (optional) * `verifyIdToken`: Custom function to verify the ID token (optional) * `disableIdTokenSignIn`: Disable sign in with ID token sent from the client (optional) * `disableDefaultScope`: Disable the provider's default scopes (optional) @@ -475,7 +475,7 @@ export const auth = betterAuth({ }, encryptOAuthTokens: true, // Encrypt OAuth tokens before storing them in the database storeStateStrategy: "database", // Store OAuth state payload in verification storage - storeAccountCookie: true, // Store account data after OAuth flow in a cookie (useful for database-less flows) + storeAccountCookie: true, // Store provider account data after OAuth flow in an encrypted cookie (useful for database-less flows) accountLinking: { enabled: true, trustedProviders: ["google", "github", "email-password"], // or async (request) => ["google", "github"] @@ -508,11 +508,15 @@ Defaults to `"database"` when a database or `secondaryStorage` is configured, an ### `storeAccountCookie` -Store account data after OAuth flow in a cookie. This is useful for database-less flows where you want to store account information (access tokens, refresh tokens, etc.) in a cookie instead of the database. +Store provider account data after an OAuth flow in an encrypted cookie. This is useful for database-less flows where OAuth token material, such as access tokens, refresh tokens, ID tokens, scopes, and token expiry, must be available without an account table. * Default: `false` * Automatically set to `true` if no database is provided +The account cookie has a five-minute max age and is refreshed when Better Auth writes updated provider account data, for example after `getAccessToken` or `/refresh-token` refreshes provider tokens. When this happens in server-side code, forward the returned `Set-Cookie` header to the browser so the refreshed account cookie is retained. + +Better Auth chunks oversized account cookies, but browsers and proxies can still enforce total cookie or header limits. Prefer database-backed account storage for providers that issue large JWTs or for production flows that need durable token storage. + When this option is enabled, you can read the decrypted account cookie in hooks or middleware using `getAccountCookie` from `better-auth/cookies`: ```ts diff --git a/packages/better-auth/src/api/routes/account.test.ts b/packages/better-auth/src/api/routes/account.test.ts index b0705a3739..f068adce48 100644 --- a/packages/better-auth/src/api/routes/account.test.ts +++ b/packages/better-auth/src/api/routes/account.test.ts @@ -1,5 +1,8 @@ import { BASE_ERROR_CODES } from "@better-auth/core/error"; -import type { GoogleProfile } from "@better-auth/core/social-providers"; +import type { + CognitoProfile, + GoogleProfile, +} from "@better-auth/core/social-providers"; import { HttpResponse, http } from "msw"; import { setupServer } from "msw/node"; import type { MockInstance } from "vitest"; @@ -1293,7 +1296,7 @@ describe("account", async () => { it("should persist refreshed idToken in account cookie during getAccessToken auto-refresh in stateless mode", async () => { const { auth, client, cookieSetter } = await getTestInstance({ - database: undefined as any, + database: undefined, socialProviders: { google: { clientId: "test", @@ -1452,6 +1455,172 @@ describe("account", async () => { expect(refreshTokenCalls).toBeGreaterThan(0); }); + /** + * @see https://github.com/better-auth/better-auth/issues/8562 + */ + it("should preserve the Cognito refresh token when getAccessToken auto-refresh receives no replacement", async () => { + const cognitoDomain = "test.auth.us-east-1.amazoncognito.com"; + const cognitoIssuer = + "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_testpool"; + const signCognitoIdToken = (jti: string) => { + const now = Math.floor(Date.now() / 1000); + return signJWT( + { + email: "cognito-refresh@test.com", + email_verified: true, + name: "Cognito User", + exp: now + 3600, + sub: "cognito-user-sub", + iat: now, + aud: "cognito-client", + iss: cognitoIssuer, + jti, + } satisfies CognitoProfile, + DEFAULT_SECRET, + ); + }; + + const { auth, client, cookieSetter } = await getTestInstance({ + database: undefined, + socialProviders: { + cognito: { + clientId: "cognito-client", + clientSecret: "cognito-secret", + domain: cognitoDomain, + region: "us-east-1", + userPoolId: "us-east-1_testpool", + }, + }, + account: { + storeAccountCookie: true, + }, + }); + const authContext = await auth.$context; + const accountDataCookieName = authContext.authCookies.accountData.name; + const refreshGrantRefreshTokens: string[] = []; + const initialIdToken = await signCognitoIdToken("initial-cognito-id-token"); + const refreshedIdToken = await signCognitoIdToken( + "refreshed-cognito-id-token", + ); + + server.use( + http.post( + `https://${cognitoDomain}/oauth2/token`, + async ({ request }) => { + const params = new URLSearchParams(await request.text()); + const grantType = params.get("grant_type"); + + if (grantType === "refresh_token") { + const refreshToken = params.get("refresh_token"); + refreshGrantRefreshTokens.push(refreshToken || ""); + return HttpResponse.json({ + access_token: "refreshed-cognito-access-token", + expires_in: 3600, + id_token: refreshedIdToken, + token_type: "Bearer", + }); + } + + return HttpResponse.json({ + access_token: "initial-cognito-access-token", + expires_in: 1, + id_token: initialIdToken, + refresh_token: "cognito-refresh-token", + token_type: "Bearer", + }); + }, + ), + ); + + const headers = new Headers(); + const signInRes = await client.signIn.social({ + provider: "cognito", + callbackURL: "/callback", + fetchOptions: { + onSuccess: cookieSetter(headers), + }, + }); + + expect(signInRes.data).toMatchObject({ + url: expect.stringContaining(cognitoDomain), + redirect: true, + }); + const state = + signInRes.data && "url" in signInRes.data && signInRes.data.url + ? new URL(signInRes.data.url).searchParams.get("state") || "" + : ""; + + await client.$fetch("/callback/cognito", { + query: { + state, + code: "test", + }, + headers, + method: "GET", + onError(context) { + expect(context.response.status).toBe(302); + cookieSetter(headers)({ response: context.response }); + }, + }); + + let refreshedAccountCookie: string | undefined; + const accessTokenResponse = await client.getAccessToken( + { + providerId: "cognito", + }, + { + headers, + onSuccess(context) { + cookieSetter(headers)(context); + const cookies = parseSetCookieHeader( + context.response.headers.get("set-cookie") || "", + ); + refreshedAccountCookie = + cookies.get(accountDataCookieName)?.value || undefined; + }, + }, + ); + + expect(accessTokenResponse.error).toBeFalsy(); + expect(accessTokenResponse.data?.accessToken).toBe( + "refreshed-cognito-access-token", + ); + expect(accessTokenResponse.data?.idToken).toBe(refreshedIdToken); + expect(refreshGrantRefreshTokens).toEqual(["cognito-refresh-token"]); + expect(refreshedAccountCookie).toBeDefined(); + await expect( + symmetricDecodeJWT( + refreshedAccountCookie!, + authContext.secret, + "better-auth-account", + ), + ).resolves.toMatchObject({ + accessToken: "refreshed-cognito-access-token", + idToken: refreshedIdToken, + refreshToken: "cognito-refresh-token", + }); + + const refreshTokenResponse = await client.$fetch<{ refreshToken?: string }>( + "/refresh-token", + { + body: { + providerId: "cognito", + }, + headers, + method: "POST", + }, + ); + + expect(refreshTokenResponse.error).toBeFalsy(); + expect(refreshTokenResponse.data?.refreshToken).toBe( + "cognito-refresh-token", + ); + expect(refreshGrantRefreshTokens).toEqual([ + "cognito-refresh-token", + "cognito-refresh-token", + ]); + }); + it("should NOT chunk account data cookies when exceeding 4KB", async () => { const { client, cookieSetter } = await getTestInstance({ secret: "better-auth.secret", @@ -1903,7 +2072,7 @@ describe("account", async () => { const refreshUpdateAge = 60; const { auth, client, cookieSetter } = await getTestInstance({ - database: undefined as any, + database: undefined, socialProviders: { google: { clientId: "test", clientSecret: "test", enabled: true }, }, diff --git a/packages/better-auth/src/social.test.ts b/packages/better-auth/src/social.test.ts index 1eccc2a0ce..31e549e342 100644 --- a/packages/better-auth/src/social.test.ts +++ b/packages/better-auth/src/social.test.ts @@ -454,7 +454,6 @@ describe("Social Providers", async (c) => { const signInRes = await client.signIn.social({ provider: "google", callbackURL: "/callback", - newUserCallbackURL: "/welcome", fetchOptions: { onSuccess: cookieSetter(headers), }, diff --git a/packages/core/src/types/init-options.ts b/packages/core/src/types/init-options.ts index a28df8bba5..05dfc7f4f0 100644 --- a/packages/core/src/types/init-options.ts +++ b/packages/core/src/types/init-options.ts @@ -1160,9 +1160,13 @@ export type BetterAuthOptions = { */ storeStateStrategy?: "database" | "cookie"; /** - * Store account data after oauth flow on a cookie + * Store provider account data after an OAuth flow in an encrypted + * cookie. This includes OAuth token material such as access tokens, + * refresh tokens, ID tokens, scopes, and token expiry. * - * This is useful for database-less flow + * This is useful for database-less flows, but large provider tokens can + * still hit browser or proxy cookie/header limits even though Better Auth + * chunks oversized account cookies. * * @default false *