diff --git a/docs/docs/reference/adapters/index.md b/docs/docs/reference/adapters/index.md index 1ad11728a6..176621415c 100644 --- a/docs/docs/reference/adapters/index.md +++ b/docs/docs/reference/adapters/index.md @@ -2,7 +2,7 @@ title: Overview --- -Using a Auth.js / NextAuth.js adapter you can connect to any database service or even several different services at the same time. The following listed official adapters are created and maintained by the community: +Using an Auth.js / NextAuth.js adapter you can connect to any database service or even several different services at the same time. The following listed official adapters are created and maintained by the community:
@@ -71,7 +71,7 @@ If you don't find an adapter for the database or service you use, you can always ## Models -Auth.js can be used with any database. Models tell you what structures Auth.js expects from your database. Models will vary slightly depending on which adapter you use, but in general, will look something like this. Each adapter's model/schema will be slightly adapted for its needs, but will look very much like this schema below: +Auth.js can be used with any database. Models tell you what structures Auth.js expects from your database. Models will vary slightly depending on which adapter you use, but in general, will look something like this: ```mermaid erDiagram @@ -96,15 +96,8 @@ erDiagram string type string provider string providerAccountId - string refresh_token string access_token - int expires_at - string token_type - string scope string id_token - string session_state - string oauth_token_secret - string oauth_token } VerificationToken { string identifier @@ -113,10 +106,10 @@ erDiagram } ``` -More information about each Model / Table can be found below. +More information about each Model/Table can be found below. :::note -You can [create your own adapter](/guides/adapters/creating-a-database-adapter) if you want to use Auth.js with a database that is not supported out of the box, or you have to change fields on any of the models. +You can [create your adapter](/guides/adapters/creating-a-database-adapter) if you want to use Auth.js with a database that is not supported out of the box, or you have to change fields on any of the models. ::: --- @@ -125,30 +118,31 @@ You can [create your own adapter](/guides/adapters/creating-a-database-adapter) The User model is for information such as the user's name and email address. -Email address is optional, but if one is specified for a User then it must be unique. +Email address is optional, but if one is specified for a User, then it must be unique. :::note -If a user first signs in with OAuth then their email address is automatically populated using the one from their OAuth profile, if the OAuth provider returns one. +If a user first signs in with an OAuth provider, then their email address is automatically populated using the one from their OAuth profile if the OAuth provider returns one. -This provides a way to contact users and for users to maintain access to their account and sign in using email in the event they are unable to sign in with the OAuth provider in future (if the [Email Provider](/getting-started/email-tutorial) is configured). +This provides a way to contact users and for users to maintain access to their account and sign in using email in the event they are unable to sign in with the OAuth provider in the future (if the [Email Provider](/reference/core/providers_email) is configured). ::: -User creation in the database is automatic, and happens when the user is logging in for the first time with a provider. The default data saved is `id`, `name`, `email` and `image`. You can add more profile data by returning extra fields in your [OAuth provider](/guides/providers/custom-provider)'s [`profile()`](/reference/core/providers#profile) callback. +User creation in the database is automatic and happens when the user is logging in for the first time with a provider. +If the first sign-in is via the [OAuth Provider](/reference/core/providers_oauth), the default data saved is `id`, `name`, `email` and `image`. You can add more profile data by returning extra fields in your [OAuth provider](/guides/providers/custom-provider)'s [`profile()`](/reference/core/providers#profile) callback. -### Account +If the first sign-in is via the [Email Provider](/reference/core/providers_email), then the saved user will have `id`, `email`, `emailVerified`, where `emailVerified` is the timestamp of when the user was created. -The Account model is for information about OAuth accounts associated with a User. It will usually contain `access_token`, `id_token` and other OAuth specific data. [`TokenSet`](https://github.com/panva/node-openid-client/blob/main/docs/README.md#new-tokensetinput) from `openid-client` might give you an idea of all the fields. +### Account -:::note -In case of an OAuth 1.0 provider (like Twitter), you will have to look for `oauth_token` and `oauth_token_secret` string fields. GitHub also has an extra `refresh_token_expires_in` integer field. You have to make sure that your database schema includes these fields. -::: +The Account model is for information about OAuth accounts associated with a User A single User can have multiple Accounts, but each Account can only have one User. -Linking Accounts to Users happen automatically, only when they have the same e-mail address, and the user is currently signed in. Check the [FAQ](/concepts/faq#security) for more information why this is a requirement. +Account creation in the database is automatic and happens when the user is logging in for the first time with a provider, or the [`Adapter.linkAccount`](/reference/core/adapters#linkaccount) method is invoked. The default data saved is `access_token`, `refresh_token`, `id_token` and `expires_at`. You can save other fields by returning them in the [OAuth provider](/guides/providers/custom-provider)'s [`account()`](/reference/core/providers#account) callback. + +Linking Accounts to Users happen automatically, only when they have the same e-mail address, and the user is currently signed in. Check the [FAQ](/concepts/faq#security) for more information on why this is a requirement. :::tip -You can manually unlink accounts, if your adapter implements the `unlinkAccount` method. Make sure to take all the necessary security steps to avoid data loss. +You can manually unlink accounts if your adapter implements the `unlinkAccount` method. Make sure to take all the necessary security steps to avoid data loss. ::: :::note @@ -162,7 +156,7 @@ The Session model is used for database sessions. It is not used if JSON Web Toke A single User can have multiple Sessions, each Session can only have one User. :::tip -When a Session is read, we check if it's `expires` field indicates an invalid session, and delete it from the database. You can also do this clean-up periodically in the background to avoid our extra delete call to the database during an active session retrieval. This might result in a slight performance increase in a few cases. +When a Session is read, we check if its `expires` field indicates an invalid session, and delete it from the database. You can also do this clean-up periodically in the background to avoid our extra delete call to the database during an active session retrieval. This might result in a slight performance increase in a few cases. ::: ### Verification Token @@ -171,7 +165,7 @@ The Verification Token model is used to store tokens for passwordless sign in. A single User can have multiple open Verification Tokens (e.g. to sign in to different devices). -It has been designed to be extendable for other verification purposes in the future (e.g. 2FA / short codes). +It has been designed to be extendable for other verification purposes in the future (e.g. 2FA / magic codes, etc.). :::note Auth.js makes sure that every token is usable only once, and by default has a short (1 day, can be configured by [`maxAge`](/guides/providers/email)) lifetime. If your user did not manage to finish the sign-in flow in time, they will have to start the sign-in process again. @@ -183,8 +177,7 @@ Due to users forgetting or failing at the sign-in flow, you might end up with un ## RDBMS Naming Convention -Auth.js / NextAuth.js uses `camelCase` for its own database rows, while respecting the conventional `snake_case` formatting for OAuth related values. If mixed casing is an issue for you, most adapters have a dedicated section on how to use a single naming convention. - +Auth.js / NextAuth.js uses `camelCase` for its database rows while respecting the conventional `snake_case` formatting for OAuth-related values. If the mixed casing is an issue for you, most adapters have a dedicated documentation section on how to force a casing convention. ## TypeScript diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js index d403102af8..72a6b539d5 100644 --- a/docs/docusaurus.config.js +++ b/docs/docusaurus.config.js @@ -7,7 +7,7 @@ const path = require("path") const coreSrc = "../packages/core/src" const providers = fs .readdirSync(path.join(__dirname, coreSrc, "/providers")) - .filter((file) => file.endsWith(".ts") && !file.startsWith("oauth")) + .filter((file) => file.endsWith(".ts")) .map((p) => `${coreSrc}/providers/${p}`) const typedocConfig = require("./typedoc.json") diff --git a/packages/core/src/adapters.ts b/packages/core/src/adapters.ts index 4d67cbce04..8dfa65dbc5 100644 --- a/packages/core/src/adapters.ts +++ b/packages/core/src/adapters.ts @@ -228,6 +228,10 @@ export interface Adapter { deleteUser?( userId: string ): Promise | Awaitable + /** + * This method is invoked internally (but optionally can be used for manual linking). + * It creates an [Account](https://authjs.dev/reference/adapters#models) in the database. + */ linkAccount?( account: AdapterAccount ): Promise | Awaitable diff --git a/packages/core/src/lib/callback-handler.ts b/packages/core/src/lib/callback-handler.ts index 2344a81dce..2348e789ca 100644 --- a/packages/core/src/lib/callback-handler.ts +++ b/packages/core/src/lib/callback-handler.ts @@ -49,7 +49,7 @@ export async function handleLogin( } const profile = _profile as AdapterUser - const account = _account as AdapterAccount + let account = _account as AdapterAccount const { createUser, @@ -154,6 +154,11 @@ export async function handleLogin( return { session, user: userByAccount, isNewUser } } else { + const { provider: p } = options as InternalOptions<"oauth" | "oidc"> + const { type, provider, providerAccountId, userId, ...tokenSet } = account + const defaults = { providerAccountId, provider, type, userId } + account = Object.assign(p.account(tokenSet), defaults) + if (user) { // If the user is already signed in and the OAuth account isn't already associated // with another user account then we can go ahead and link the accounts safely. diff --git a/packages/core/src/lib/oauth/callback.ts b/packages/core/src/lib/oauth/callback.ts index 04f5079dc2..456b924655 100644 --- a/packages/core/src/lib/oauth/callback.ts +++ b/packages/core/src/lib/oauth/callback.ts @@ -3,6 +3,7 @@ import * as o from "oauth4webapi" import { OAuthCallbackError, OAuthProfileParseError } from "../../errors.js" import type { + Account, InternalOptions, LoggerInstance, Profile, @@ -123,8 +124,8 @@ export async function handleOAuth( throw new Error("TODO: Handle www-authenticate challenges as needed") } - let profile: Profile = {} - let tokens: TokenSet + let profile: Profile + let tokens: TokenSet & Pick if (provider.type === "oidc") { const nonce = await checks.nonce.use(cookies, resCookies, options) @@ -162,37 +163,49 @@ export async function handleOAuth( (tokens as any).access_token ) profile = await userinfoResponse.json() + } else { + throw new TypeError("No userinfo endpoint configured") } } - const profileResult = await getProfile(profile, provider, tokens, logger) + if (tokens.expires_in) { + tokens.expires_at = + Math.floor(Date.now() / 1000) + Number(tokens.expires_in) + } + + const profileResult = await getUserAndProfile( + profile, + provider, + tokens, + logger + ) return { ...profileResult, cookies: resCookies } } /** Returns profile, raw profile and auth provider details */ -async function getProfile( +async function getUserAndProfile( OAuthProfile: Profile, provider: OAuthConfigInternal, tokens: TokenSet, logger: LoggerInstance ) { try { - const profile = await provider.profile(OAuthProfile, tokens) - profile.email = profile.email?.toLowerCase() + const user = await provider.profile(OAuthProfile, tokens) + user.email = user.email?.toLowerCase() - if (!profile.id) { + if (!user.id) { throw new TypeError( - `Profile id is missing in ${provider.name} OAuth profile response` + `User id is missing in ${provider.name} OAuth profile response` ) } return { - profile, + user, account: { provider: provider.id, type: provider.type, - providerAccountId: profile.id.toString(), + providerAccountId: user.id.toString(), ...tokens, }, OAuthProfile, diff --git a/packages/core/src/lib/providers.ts b/packages/core/src/lib/providers.ts index 2da2d80b39..496e34b290 100644 --- a/packages/core/src/lib/providers.ts +++ b/packages/core/src/lib/providers.ts @@ -1,13 +1,15 @@ import { merge } from "./utils/merge.js" import type { + AccountCallback, OAuthConfig, OAuthConfigInternal, OAuthEndpointType, OAuthUserConfig, + ProfileCallback, Provider, } from "../providers/index.js" -import type { AuthConfig, InternalProvider } from "../types.js" +import type { AuthConfig, InternalProvider, Profile } from "../types.js" /** * Adds `signinUrl` and `callbackUrl` to each provider @@ -77,18 +79,47 @@ function normalizeOAuth( checks, userinfo, profile: c.profile ?? defaultProfile, + account: c.account ?? defaultAccount, } } -function defaultProfile(profile: any) { - return { +/** + * Returns basic user profile from the userinfo response/`id_token` claims. + * @see https://authjs.dev/reference/adapters#user + * @see https://openid.net/specs/openid-connect-core-1_0.html#IDToken + * @see https://openid.net/specs/openid-connect-core-1_0.html#UserInfo + */ +const defaultProfile: ProfileCallback = (profile) => { + return stripUndefined({ id: profile.sub ?? profile.id, - name: - profile.name ?? profile.nickname ?? profile.preferred_username ?? null, - email: profile.email ?? null, - image: profile.picture ?? null, - } + name: profile.name ?? profile.nickname ?? profile.preferred_username, + email: profile.email, + image: profile.picture, + }) } + +/** + * Returns basic OAuth/OIDC values from the token response. + * @see https://www.ietf.org/rfc/rfc6749.html#section-5.1 + * @see https://openid.net/specs/openid-connect-core-1_0.html#TokenResponse + * @see https://authjs.dev/reference/adapters#account + * + * @todo Return `refresh_token` and `expires_at` as well when built-in + * refresh token support is added. (Can make it opt-in first with a flag). + */ +const defaultAccount: AccountCallback = (account) => { + return stripUndefined({ + access_token: account.access_token, + id_token: account.id_token, + }) +} + +function stripUndefined(o: T): T { + const result = {} as any + for (let [k, v] of Object.entries(o)) v !== undefined && (result[k] = v) + return result as T +} + function normalizeEndpoint( e?: OAuthConfig[OAuthEndpointType], issuer?: string diff --git a/packages/core/src/lib/routes/callback.ts b/packages/core/src/lib/routes/callback.ts index 4b0fead9fb..dba511ead9 100644 --- a/packages/core/src/lib/routes/callback.ts +++ b/packages/core/src/lib/routes/callback.ts @@ -68,14 +68,18 @@ export async function callback(params: { logger.debug("authorization result", authorizationResult) - const { profile, account, OAuthProfile } = authorizationResult + const { + user: userFromProvider, + account, + OAuthProfile, + } = authorizationResult // If we don't have a profile object then either something went wrong // or the user cancelled signing in. We don't know which, so we just // direct the user to the signin page for now. We could do something // else in future. // TODO: Handle user cancelling signin - if (!profile || !account || !OAuthProfile) { + if (!userFromProvider || !account || !OAuthProfile) { return { redirect: `${url}/signin`, cookies } } @@ -83,7 +87,7 @@ export async function callback(params: { // Attempt to get Profile from OAuth provider details before invoking // signIn callback - but if no user object is returned, that is fine // (that just means it's a new user signing in for the first time). - let userOrProfile = profile + let userByAccountOrFromProvider if (adapter) { const { getUserByAccount } = adapter const userByAccount = await getUserByAccount({ @@ -91,11 +95,15 @@ export async function callback(params: { provider: provider.id, }) - if (userByAccount) userOrProfile = userByAccount + if (userByAccount) userByAccountOrFromProvider = userByAccount } const unauthorizedOrError = await handleAuthorized( - { user: userOrProfile, account, profile: OAuthProfile }, + { + user: userByAccountOrFromProvider, + account, + profile: OAuthProfile, + }, options ) @@ -104,7 +112,7 @@ export async function callback(params: { // Sign user in const { user, session, isNewUser } = await handleLogin( sessionStore.value, - profile, + userFromProvider, account, options ) @@ -152,7 +160,7 @@ export async function callback(params: { }) } - await events.signIn?.({ user, account, profile, isNewUser }) + await events.signIn?.({ user, account, profile: OAuthProfile, isNewUser }) // Handle first logins on new accounts // e.g. option to send users to a new account landing page on initial login diff --git a/packages/core/src/providers/oauth.ts b/packages/core/src/providers/oauth.ts index 8069abef90..0f13eae610 100644 --- a/packages/core/src/providers/oauth.ts +++ b/packages/core/src/providers/oauth.ts @@ -52,7 +52,10 @@ interface AdvancedEndpointHandler

{ conform?: (response: Response) => Awaitable } -/** Either an URL (containing all the parameters) or an object with more granular control. */ +/** + * Either an URL (containing all the parameters) or an object with more granular control. + * @internal + */ export type EndpointHandler< P extends UrlParams, C = any, @@ -92,6 +95,8 @@ export type ProfileCallback = ( tokens: TokenSet ) => Awaitable +export type AccountCallback = (account: TokenSet) => TokenSet + export interface OAuthProviderButtonStyles { logo: string logoDark: string @@ -138,13 +143,25 @@ export interface OAuth2Config userinfo?: string | UserinfoEndpointHandler type: "oauth" /** - * Receives the profile object returned by the OAuth provider, and returns the user object. - * This will be used to create the user in the database. + * Receives the full {@link Profile} returned by the OAuth provider, and returns a subset. + * It is used to create the user in the database. + * * Defaults to: `id`, `email`, `name`, `image` * - * [Documentation](https://authjs.dev/reference/adapters/models#user) + * @see [Database Adapter: User model](https://authjs.dev/reference/adapters#user) */ profile?: ProfileCallback + /** + * Receives the full {@link TokenSet} returned by the OAuth provider, and returns a subset. + * It is used to create the account associated with a user in the database. + * + * Defaults to: `access_token` and `id_token` + * + * @see [Database Adapter: Account model](https://authjs.dev/reference/adapters#account) + * @see https://openid.net/specs/openid-connect-core-1_0.html#TokenResponse + * @see https://www.ietf.org/rfc/rfc6749.html#section-5.1 + */ + account?: AccountCallback /** * The CSRF protection performed on the callback endpoint. * @default ["pkce"] @@ -190,7 +207,11 @@ export interface OAuth2Config options?: OAuthUserConfig } -/** TODO: Document */ +/** + * Extension of the {@link OAuth2Config}. + * + * @see https://openid.net/specs/openid-connect-core-1_0.html + */ export interface OIDCConfig extends Omit, "type" | "checks"> { type: "oidc" @@ -204,6 +225,7 @@ export type OAuthEndpointType = "authorization" | "token" | "userinfo" /** * We parsed `authorization`, `token` and `userinfo` * to always contain a valid `URL`, with the params + * @internal */ export type OAuthConfigInternal = Omit< OAuthConfig, @@ -229,7 +251,10 @@ export type OAuthConfigInternal = Omit< * */ redirectProxyUrl?: OAuth2Config["redirectProxyUrl"] -} & Pick>, "clientId" | "checks" | "profile"> +} & Pick< + Required>, + "clientId" | "checks" | "profile" | "account" + > export type OIDCConfigInternal = OAuthConfigInternal & { checks: OIDCConfig["checks"] diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 8d5a5b4309..5d154be87b 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -116,16 +116,58 @@ export interface Account extends Partial { providerAccountId: string /** Provider's type for this account */ type: ProviderType - /** id of the user this account belongs to */ + /** + * id of the user this account belongs to + * + * @see https://authjs.dev/reference/adapters#user + */ userId?: string + /** + * Calculated value based on {@link OAuth2TokenEndpointResponse.expires_in}. + * + * It is the absolute timestamp (in seconds) when the {@link OAuth2TokenEndpointResponse.access_token} expires. + * + * This value can be used for implementing token rotation together with {@link OAuth2TokenEndpointResponse.refresh_token}. + * + * @see https://authjs.dev/guides/basics/refresh-token-rotation#database-strategy + * @see https://www.rfc-editor.org/rfc/rfc6749#section-5.1 + */ + expires_at?: number } -/** The OAuth profile returned from your provider */ +/** + * The user info returned from your OAuth provider. + * + * @see https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims + */ export interface Profile { - sub?: string | null - name?: string | null - email?: string | null - image?: string | null + sub: string + name?: string + given_name?: string + family_name?: string + middle_name?: string + nickname?: string + preferred_username?: string + profile?: string + picture?: string + website?: string + email?: string + email_verified?: boolean + gender?: string + birthdate?: string + zoneinfo?: string + locale?: string + phone_number?: string + updated_at?: number + address?: { + formatted?: string + street_address?: string + locality?: string + region?: string + postal_code?: string + country?: string + } + [claim: string]: unknown } /** [Documentation](https://authjs.dev/guides/basics/callbacks) */