From d7cc8ef9bf7233123bf212ae10c46185d6b3cc61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20Orb=C3=A1n?= Date: Mon, 24 Apr 2023 17:13:54 +0300 Subject: [PATCH 1/7] feat: map Account before saving to database --- packages/core/src/lib/callback-handler.ts | 9 ++++++- packages/core/src/lib/providers.ts | 29 ++++++++++++++++++++++- packages/core/src/providers/oauth.ts | 21 ++++++++++++++-- 3 files changed, 55 insertions(+), 4 deletions(-) diff --git a/packages/core/src/lib/callback-handler.ts b/packages/core/src/lib/callback-handler.ts index 2344a81dce..1dcb7a2784 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,13 @@ export async function handleLogin( return { session, user: userByAccount, isNewUser } } else { + const { provider } = options as InternalOptions<"oauth" | "oidc"> + account = Object.assign(provider.account({ ...account }), { + providerAccountId: account.providerAccountId, + provider: account.provider, + type: account.type, + }) as AdapterAccount + 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/providers.ts b/packages/core/src/lib/providers.ts index 2da2d80b39..2622612009 100644 --- a/packages/core/src/lib/providers.ts +++ b/packages/core/src/lib/providers.ts @@ -7,7 +7,7 @@ import type { OAuthUserConfig, Provider, } from "../providers/index.js" -import type { AuthConfig, InternalProvider } from "../types.js" +import type { Account, AuthConfig, InternalProvider } from "../types.js" /** * Adds `signinUrl` and `callbackUrl` to each provider @@ -77,6 +77,7 @@ function normalizeOAuth( checks, userinfo, profile: c.profile ?? defaultProfile, + account: c.account ?? defaultAccount, } } @@ -89,6 +90,32 @@ function defaultProfile(profile: any) { image: profile.picture ?? null, } } + +/** + * 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 + */ +function defaultAccount(account: Account): Account { + return stripUndefined({ + provider: account.provider, + type: account.type, + providerAccountId: account.providerAccountId, + id: account.id, + userId: account.userId, + access_token: account.access_token, + expires_in: account.expires_in, + id_token: account.id_token, + refresh_token: account.refresh_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/providers/oauth.ts b/packages/core/src/providers/oauth.ts index 8069abef90..e176bd71ca 100644 --- a/packages/core/src/providers/oauth.ts +++ b/packages/core/src/providers/oauth.ts @@ -1,6 +1,7 @@ import type { Client } from "oauth4webapi" import type { CommonProviderOptions } from "../providers/index.js" import type { + Account, AuthConfig, Awaitable, Profile, @@ -92,6 +93,8 @@ export type ProfileCallback = ( tokens: TokenSet ) => Awaitable +export type AccountCallback = (account: Account) => Account + export interface OAuthProviderButtonStyles { logo: string logoDark: string @@ -142,9 +145,20 @@ export interface OAuth2Config * This will be used to create the user in the database. * Defaults to: `id`, `email`, `name`, `image` * - * [Documentation](https://authjs.dev/reference/adapters/models#user) + * [Documentation](https://authjs.dev/reference/adapters#user) */ profile?: ProfileCallback + + /** + * Receives the Account object returned by the OAuth provider, and returns the user object. + * This will be used to create the account associated with a user in the database. + * + * [Documentation](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"] @@ -229,7 +243,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"] From b05f9ec9b89cdf5490e9b40e512454d45459034b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20Orb=C3=A1n?= Date: Wed, 26 Apr 2023 12:40:31 +0200 Subject: [PATCH 2/7] document `acconut()`, explain default behaviour --- docs/docs/reference/adapters/index.md | 38 +++++++++++++-------------- docs/docusaurus.config.js | 2 +- packages/core/src/adapters.ts | 4 +++ packages/core/src/providers/oauth.ts | 18 ++++++++----- packages/core/src/types.ts | 17 +++++++++++- 5 files changed, 52 insertions(+), 27 deletions(-) diff --git a/docs/docs/reference/adapters/index.md b/docs/docs/reference/adapters/index.md index 1ad11728a6..2a732a85b8 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 @@ -113,10 +113,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 +125,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 +163,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 +172,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 +184,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/providers/oauth.ts b/packages/core/src/providers/oauth.ts index e176bd71ca..0150dba25a 100644 --- a/packages/core/src/providers/oauth.ts +++ b/packages/core/src/providers/oauth.ts @@ -53,7 +53,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, @@ -145,16 +148,14 @@ export interface OAuth2Config * This will be used to create the user in the database. * Defaults to: `id`, `email`, `name`, `image` * - * [Documentation](https://authjs.dev/reference/adapters#user) + * @see [Database Adapter: User model](https://authjs.dev/reference/adapters#user) */ profile?: ProfileCallback - /** * Receives the Account object returned by the OAuth provider, and returns the user object. * This will be used to create the account associated with a user in the database. * - * [Documentation](https://authjs.dev/reference/adapters#account) - * + * @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 */ @@ -204,7 +205,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" @@ -218,6 +223,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, diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 8d5a5b4309..4af727fc55 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -116,8 +116,23 @@ 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 */ From b0d98a3057be08d603017305878ccdd58e0c7ad1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20Orb=C3=A1n?= Date: Wed, 26 Apr 2023 12:41:21 +0200 Subject: [PATCH 3/7] generate `expires_at` based on `expires_in` Fixes #6538 --- docs/docs/reference/adapters/index.md | 7 ------- packages/core/src/lib/oauth/callback.ts | 8 +++++++- packages/core/src/lib/providers.ts | 16 ++++++++++++---- 3 files changed, 19 insertions(+), 12 deletions(-) diff --git a/docs/docs/reference/adapters/index.md b/docs/docs/reference/adapters/index.md index 2a732a85b8..176621415c 100644 --- a/docs/docs/reference/adapters/index.md +++ b/docs/docs/reference/adapters/index.md @@ -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 diff --git a/packages/core/src/lib/oauth/callback.ts b/packages/core/src/lib/oauth/callback.ts index 04f5079dc2..64acb53eef 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, @@ -124,7 +125,7 @@ export async function handleOAuth( } let profile: Profile = {} - let tokens: TokenSet + let tokens: TokenSet & Pick if (provider.type === "oidc") { const nonce = await checks.nonce.use(cookies, resCookies, options) @@ -165,6 +166,11 @@ export async function handleOAuth( } } + if (tokens.expires_in) { + tokens.expires_at = + Math.floor(Date.now() / 1000) + Number(tokens.expires_in) + } + const profileResult = await getProfile(profile, provider, tokens, logger) return { ...profileResult, cookies: resCookies } diff --git a/packages/core/src/lib/providers.ts b/packages/core/src/lib/providers.ts index 2622612009..9081ed85ee 100644 --- a/packages/core/src/lib/providers.ts +++ b/packages/core/src/lib/providers.ts @@ -7,7 +7,7 @@ import type { OAuthUserConfig, Provider, } from "../providers/index.js" -import type { Account, AuthConfig, InternalProvider } from "../types.js" +import type { Account, AuthConfig, InternalProvider, User } from "../types.js" /** * Adds `signinUrl` and `callbackUrl` to each provider @@ -81,7 +81,13 @@ function normalizeOAuth( } } -function defaultProfile(profile: any) { +/** + * 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 + */ +function defaultProfile(profile: any): User { return { id: profile.sub ?? profile.id, name: @@ -95,6 +101,10 @@ function defaultProfile(profile: any) { * 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 `access_token_expires_at` as well when built-in + * refresh token support is added. (Can make it opt-in first with a flag). */ function defaultAccount(account: Account): Account { return stripUndefined({ @@ -104,9 +114,7 @@ function defaultAccount(account: Account): Account { id: account.id, userId: account.userId, access_token: account.access_token, - expires_in: account.expires_in, id_token: account.id_token, - refresh_token: account.refresh_token, }) } From 698fe7c4f0af80ac70dec574fb162e7aa650c99e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20Orb=C3=A1n?= Date: Wed, 26 Apr 2023 12:52:54 +0200 Subject: [PATCH 4/7] rename --- packages/core/src/lib/providers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/lib/providers.ts b/packages/core/src/lib/providers.ts index 9081ed85ee..8b5055dc70 100644 --- a/packages/core/src/lib/providers.ts +++ b/packages/core/src/lib/providers.ts @@ -103,7 +103,7 @@ function defaultProfile(profile: any): User { * @see https://openid.net/specs/openid-connect-core-1_0.html#TokenResponse * @see https://authjs.dev/reference/adapters#account * - * @todo Return `refresh_token` and `access_token_expires_at` as well when built-in + * @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). */ function defaultAccount(account: Account): Account { From f4d1186107e5a032351cc2fa490db5b634ceeff9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20Orb=C3=A1n?= Date: Wed, 26 Apr 2023 12:54:01 +0200 Subject: [PATCH 5/7] strip undefined on `defaultProfile` --- packages/core/src/lib/providers.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/core/src/lib/providers.ts b/packages/core/src/lib/providers.ts index 8b5055dc70..80f2d9128f 100644 --- a/packages/core/src/lib/providers.ts +++ b/packages/core/src/lib/providers.ts @@ -88,13 +88,12 @@ function normalizeOAuth( * @see https://openid.net/specs/openid-connect-core-1_0.html#UserInfo */ function defaultProfile(profile: any): User { - return { + 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, + }) } /** From acad75c575d48f46397dcc874fec74e44db5ac53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20Orb=C3=A1n?= Date: Sun, 30 Apr 2023 12:10:27 +0200 Subject: [PATCH 6/7] don't forward defaults to account callback --- packages/core/src/lib/callback-handler.ts | 10 ++++------ packages/core/src/lib/providers.ts | 9 +++------ packages/core/src/providers/oauth.ts | 4 ++-- 3 files changed, 9 insertions(+), 14 deletions(-) diff --git a/packages/core/src/lib/callback-handler.ts b/packages/core/src/lib/callback-handler.ts index 1dcb7a2784..2348e789ca 100644 --- a/packages/core/src/lib/callback-handler.ts +++ b/packages/core/src/lib/callback-handler.ts @@ -154,12 +154,10 @@ export async function handleLogin( return { session, user: userByAccount, isNewUser } } else { - const { provider } = options as InternalOptions<"oauth" | "oidc"> - account = Object.assign(provider.account({ ...account }), { - providerAccountId: account.providerAccountId, - provider: account.provider, - type: account.type, - }) as AdapterAccount + 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 diff --git a/packages/core/src/lib/providers.ts b/packages/core/src/lib/providers.ts index 80f2d9128f..a1291a1ec1 100644 --- a/packages/core/src/lib/providers.ts +++ b/packages/core/src/lib/providers.ts @@ -1,13 +1,14 @@ import { merge } from "./utils/merge.js" import type { + AccountCallback, OAuthConfig, OAuthConfigInternal, OAuthEndpointType, OAuthUserConfig, Provider, } from "../providers/index.js" -import type { Account, AuthConfig, InternalProvider, User } from "../types.js" +import type { AuthConfig, InternalProvider, User } from "../types.js" /** * Adds `signinUrl` and `callbackUrl` to each provider @@ -105,13 +106,9 @@ function defaultProfile(profile: any): User { * @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). */ -function defaultAccount(account: Account): Account { +const defaultAccount: AccountCallback = (account) => { return stripUndefined({ - provider: account.provider, - type: account.type, - providerAccountId: account.providerAccountId, id: account.id, - userId: account.userId, access_token: account.access_token, id_token: account.id_token, }) diff --git a/packages/core/src/providers/oauth.ts b/packages/core/src/providers/oauth.ts index 0150dba25a..62037191cb 100644 --- a/packages/core/src/providers/oauth.ts +++ b/packages/core/src/providers/oauth.ts @@ -96,7 +96,7 @@ export type ProfileCallback = ( tokens: TokenSet ) => Awaitable -export type AccountCallback = (account: Account) => Account +export type AccountCallback = (account: TokenSet) => TokenSet export interface OAuthProviderButtonStyles { logo: string @@ -159,7 +159,7 @@ export interface OAuth2Config * @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 + account?: AccountCallback /** * The CSRF protection performed on the callback endpoint. * @default ["pkce"] From 3c537f112077d320c40198b09d4a968875585aa5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20Orb=C3=A1n?= Date: Sun, 30 Apr 2023 12:53:43 +0200 Subject: [PATCH 7/7] improve internal namings, types, docs --- packages/core/src/lib/oauth/callback.ts | 25 ++++++++++------ packages/core/src/lib/providers.ts | 6 ++-- packages/core/src/lib/routes/callback.ts | 22 +++++++++----- packages/core/src/providers/oauth.ts | 12 ++++---- packages/core/src/types.ts | 37 ++++++++++++++++++++---- 5 files changed, 73 insertions(+), 29 deletions(-) diff --git a/packages/core/src/lib/oauth/callback.ts b/packages/core/src/lib/oauth/callback.ts index 64acb53eef..456b924655 100644 --- a/packages/core/src/lib/oauth/callback.ts +++ b/packages/core/src/lib/oauth/callback.ts @@ -124,7 +124,7 @@ export async function handleOAuth( throw new Error("TODO: Handle www-authenticate challenges as needed") } - let profile: Profile = {} + let profile: Profile let tokens: TokenSet & Pick if (provider.type === "oidc") { @@ -163,6 +163,8 @@ export async function handleOAuth( (tokens as any).access_token ) profile = await userinfoResponse.json() + } else { + throw new TypeError("No userinfo endpoint configured") } } @@ -171,34 +173,39 @@ export async function handleOAuth( Math.floor(Date.now() / 1000) + Number(tokens.expires_in) } - const profileResult = await getProfile(profile, provider, tokens, logger) + 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 a1291a1ec1..496e34b290 100644 --- a/packages/core/src/lib/providers.ts +++ b/packages/core/src/lib/providers.ts @@ -6,9 +6,10 @@ import type { OAuthConfigInternal, OAuthEndpointType, OAuthUserConfig, + ProfileCallback, Provider, } from "../providers/index.js" -import type { AuthConfig, InternalProvider, User } from "../types.js" +import type { AuthConfig, InternalProvider, Profile } from "../types.js" /** * Adds `signinUrl` and `callbackUrl` to each provider @@ -88,7 +89,7 @@ function normalizeOAuth( * @see https://openid.net/specs/openid-connect-core-1_0.html#IDToken * @see https://openid.net/specs/openid-connect-core-1_0.html#UserInfo */ -function defaultProfile(profile: any): User { +const defaultProfile: ProfileCallback = (profile) => { return stripUndefined({ id: profile.sub ?? profile.id, name: profile.name ?? profile.nickname ?? profile.preferred_username, @@ -108,7 +109,6 @@ function defaultProfile(profile: any): User { */ const defaultAccount: AccountCallback = (account) => { return stripUndefined({ - id: account.id, access_token: account.access_token, id_token: account.id_token, }) 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 62037191cb..0f13eae610 100644 --- a/packages/core/src/providers/oauth.ts +++ b/packages/core/src/providers/oauth.ts @@ -1,7 +1,6 @@ import type { Client } from "oauth4webapi" import type { CommonProviderOptions } from "../providers/index.js" import type { - Account, AuthConfig, Awaitable, Profile, @@ -144,16 +143,19 @@ 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` * * @see [Database Adapter: User model](https://authjs.dev/reference/adapters#user) */ profile?: ProfileCallback /** - * Receives the Account object returned by the OAuth provider, and returns the user object. - * This will be used to create the account associated with a user in the database. + * 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 diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 4af727fc55..5d154be87b 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -135,12 +135,39 @@ export interface Account extends Partial { 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) */