mirror of
https://github.com/better-auth/better-auth.git
synced 2026-07-16 14:38:30 -05:00
docs: clarify stateless Cognito token refresh (#10092)
This commit is contained in:
@@ -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.
|
||||
|
||||
<Callout type="info">
|
||||
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).
|
||||
|
||||
@@ -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.
|
||||
</Callout>
|
||||
|
||||
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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user