Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(adapters): add Account mapping before database write #7369

Merged
merged 8 commits into from
Apr 30, 2023
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 19 additions & 26 deletions docs/docs/reference/adapters/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

<div class="adapter-card-list">
<a href="/reference/adapter/dgraph" class="adapter-card">
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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.
:::

---
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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.
Expand All @@ -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

Expand Down
2 changes: 1 addition & 1 deletion docs/docusaurus.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/adapters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,10 @@ export interface Adapter {
deleteUser?(
userId: string
): Promise<void> | Awaitable<AdapterUser | null | undefined>
/**
* 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<void> | Awaitable<AdapterAccount | null | undefined>
Expand Down
9 changes: 8 additions & 1 deletion packages/core/src/lib/callback-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 }), {
balazsorban44 marked this conversation as resolved.
Show resolved Hide resolved
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.
Expand Down
8 changes: 7 additions & 1 deletion packages/core/src/lib/oauth/callback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as o from "oauth4webapi"
import { OAuthCallbackError, OAuthProfileParseError } from "../../errors.js"

import type {
Account,
InternalOptions,
LoggerInstance,
Profile,
Expand Down Expand Up @@ -124,7 +125,7 @@ export async function handleOAuth(
}

let profile: Profile = {}
let tokens: TokenSet
let tokens: TokenSet & Pick<Account, "expires_at">

if (provider.type === "oidc") {
const nonce = await checks.nonce.use(cookies, resCookies, options)
Expand Down Expand Up @@ -165,6 +166,11 @@ export async function handleOAuth(
}
}

if (tokens.expires_in) {
tokens.expires_at =
Math.floor(Date.now() / 1000) + Number(tokens.expires_in)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we don't use openid-client anymore, this is implemented here now.

Note, we do not save this by default yet, as refresh token rotation is not currently added, but we provide this value to ease migration/support user-land rotation.

}

const profileResult = await getProfile(profile, provider, tokens, logger)

return { ...profileResult, cookies: resCookies }
Expand Down
50 changes: 42 additions & 8 deletions packages/core/src/lib/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type {
OAuthUserConfig,
Provider,
} from "../providers/index.js"
import type { AuthConfig, InternalProvider } from "../types.js"
import type { Account, AuthConfig, InternalProvider, User } from "../types.js"

/**
* Adds `signinUrl` and `callbackUrl` to each provider
Expand Down Expand Up @@ -77,18 +77,52 @@ 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
*/
function defaultProfile(profile: any): User {
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).
*/
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,
id_token: account.id_token,
})
}

function stripUndefined<T extends object>(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<any>[OAuthEndpointType],
issuer?: string
Expand Down
31 changes: 27 additions & 4 deletions packages/core/src/providers/oauth.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Client } from "oauth4webapi"
import type { CommonProviderOptions } from "../providers/index.js"
import type {
Account,
AuthConfig,
Awaitable,
Profile,
Expand Down Expand Up @@ -52,7 +53,10 @@ interface AdvancedEndpointHandler<P extends UrlParams, C, R> {
conform?: (response: Response) => Awaitable<Response | undefined>
}

/** 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,
Expand Down Expand Up @@ -92,6 +96,8 @@ export type ProfileCallback<Profile> = (
tokens: TokenSet
) => Awaitable<User>

export type AccountCallback<Account> = (account: Account) => Account

export interface OAuthProviderButtonStyles {
logo: string
logoDark: string
Expand Down Expand Up @@ -142,9 +148,18 @@ export interface OAuth2Config<Profile>
* 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)
* @see [Database Adapter: User model](https://authjs.dev/reference/adapters#user)
*/
profile?: ProfileCallback<Profile>
/**
* 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.
*
* @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<Account>
/**
* The CSRF protection performed on the callback endpoint.
* @default ["pkce"]
Expand Down Expand Up @@ -190,7 +205,11 @@ export interface OAuth2Config<Profile>
options?: OAuthUserConfig<Profile>
}

/** TODO: Document */
/**
* Extension of the {@link OAuth2Config}.
*
* @see https://openid.net/specs/openid-connect-core-1_0.html
*/
export interface OIDCConfig<Profile>
extends Omit<OAuth2Config<Profile>, "type" | "checks"> {
type: "oidc"
Expand All @@ -204,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<Profile> = Omit<
OAuthConfig<Profile>,
Expand All @@ -229,7 +249,10 @@ export type OAuthConfigInternal<Profile> = Omit<
*
*/
redirectProxyUrl?: OAuth2Config<Profile>["redirectProxyUrl"]
} & Pick<Required<OAuthConfig<Profile>>, "clientId" | "checks" | "profile">
} & Pick<
Required<OAuthConfig<Profile>>,
"clientId" | "checks" | "profile" | "account"
>

export type OIDCConfigInternal<Profile> = OAuthConfigInternal<Profile> & {
checks: OIDCConfig<Profile>["checks"]
Expand Down
17 changes: 16 additions & 1 deletion packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,8 +116,23 @@ export interface Account extends Partial<OpenIDTokenEndpointResponse> {
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 */
Expand Down