mirror of
https://github.com/SrIzan10/next-auth.git
synced 2026-05-01 10:55:20 +00:00
Compare commits
13 Commits
next-auth@
...
@auth/core
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7af366a3b | ||
|
|
3bdf7f56f0 | ||
|
|
b00a694a4f | ||
|
|
6ffecfb87d | ||
|
|
22c29361e5 | ||
|
|
b157554a5f | ||
|
|
08fed7eddd | ||
|
|
b5e1b19771 | ||
|
|
6680860293 | ||
|
|
c7d93c61e0 | ||
|
|
a7b6a29773 | ||
|
|
092ab9c128 | ||
|
|
36f44a869a |
@@ -18,6 +18,7 @@ module.exports = {
|
||||
parserOptions: {
|
||||
project: [
|
||||
path.resolve(__dirname, "./packages/**/tsconfig.eslint.json"),
|
||||
path.resolve(__dirname, "./packages/frameworks/**/tsconfig.json"),
|
||||
path.resolve(__dirname, "./apps/**/tsconfig.json"),
|
||||
],
|
||||
},
|
||||
|
||||
18
.gitignore
vendored
18
.gitignore
vendored
@@ -78,4 +78,20 @@ test.schema.gql
|
||||
|
||||
# docusaurus
|
||||
docs/.docusaurus
|
||||
docs/providers.json
|
||||
docs/providers.json
|
||||
|
||||
# Core
|
||||
packages/core/adapters.*
|
||||
packages/core/index.*
|
||||
packages/core/jwt
|
||||
packages/core/lib
|
||||
packages/core/providers
|
||||
|
||||
|
||||
# SvelteKit
|
||||
packages/frameworks-sveltekit/index.*
|
||||
packages/frameworks-sveltekit/client.*
|
||||
packages/frameworks-sveltekit/.svelte-kit
|
||||
packages/frameworks-sveltekit/package
|
||||
packages/frameworks-sveltekit/vite.config.js.timestamp-*
|
||||
packages/frameworks-sveltekit/vite.config.ts.timestamp-*
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
# CHANGELOG
|
||||
|
||||
The changelog is automatically updated using
|
||||
[scripts/release/index.ts](https://github.com/nextauthjs/next-auth/tree/main/scripts/index.ts). You
|
||||
can see it on the [releases page](../../releases).
|
||||
@@ -1,76 +0,0 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
In the interest of fostering an open and welcoming environment, we as
|
||||
contributors and maintainers pledge to making participation in our project and
|
||||
our community a harassment-free experience for everyone, regardless of age, body
|
||||
size, disability, ethnicity, sex characteristics, gender identity and expression,
|
||||
level of experience, education, socio-economic status, nationality, personal
|
||||
appearance, race, religion, or sexual identity and orientation.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to creating a positive environment
|
||||
include:
|
||||
|
||||
- Using welcoming and inclusive language
|
||||
- Being respectful of differing viewpoints and experiences
|
||||
- Gracefully accepting constructive criticism
|
||||
- Focusing on what is best for the community
|
||||
- Showing empathy towards other community members
|
||||
|
||||
Examples of unacceptable behavior by participants include:
|
||||
|
||||
- The use of sexualized language or imagery and unwelcome sexual attention or
|
||||
advances
|
||||
- Trolling, insulting/derogatory comments, and personal or political attacks
|
||||
- Public or private harassment
|
||||
- Publishing others' private information, such as a physical or electronic
|
||||
address, without explicit permission
|
||||
- Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Our Responsibilities
|
||||
|
||||
Project maintainers are responsible for clarifying the standards of acceptable
|
||||
behavior and are expected to take appropriate and fair corrective action in
|
||||
response to any instances of unacceptable behavior.
|
||||
|
||||
Project maintainers have the right and responsibility to remove, edit, or
|
||||
reject comments, commits, code, wiki edits, issues, and other contributions
|
||||
that are not aligned to this Code of Conduct, or to ban temporarily or
|
||||
permanently any contributor for other behaviors that they deem inappropriate,
|
||||
threatening, offensive, or harmful.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies both within project spaces and in public spaces
|
||||
when an individual is representing the project or its community. Examples of
|
||||
representing a project or community include using an official project e-mail
|
||||
address, posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event. Representation of a project may be
|
||||
further defined and clarified by project maintainers.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported by contacting hi@thvu.dev, info@balazsorban.com, yo@ndo.dev and me@iaincollins.com.
|
||||
All complaints will be reviewed and investigated and will result in a response
|
||||
that is deemed necessary and appropriate to the circumstances. The project team
|
||||
is obligated to maintain confidentiality with regard to the reporter of an
|
||||
incident. Further details of specific enforcement policies may be posted separately.
|
||||
|
||||
Project maintainers who do not follow or enforce the Code of Conduct in good
|
||||
faith may face temporary or permanent repercussions as determined by other
|
||||
members of the project's leadership.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
|
||||
available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
For answers to common questions about this code of conduct, see
|
||||
https://www.contributor-covenant.org/faq
|
||||
122
CONTRIBUTING.md
122
CONTRIBUTING.md
@@ -1,122 +0,0 @@
|
||||
# Contributing guide
|
||||
|
||||
Contributions and feedback on your experience of using this software are welcome.
|
||||
|
||||
This includes bug reports, feature requests, ideas, pull requests, and examples of how you have used this software.
|
||||
|
||||
Please see the [Code of Conduct](CODE_OF_CONDUCT.md) and follow any templates configured in GitHub when reporting bugs, requesting enhancements, or contributing code.
|
||||
|
||||
Please raise any significant new functionality or breaking change an issue for discussion before raising a Pull Request for it.
|
||||
|
||||
## For contributors
|
||||
|
||||
Anyone can be a contributor. Either you found a typo, or you have an awesome feature request you could implement, we encourage you to create a Pull Request.
|
||||
|
||||
Before contributing, we recommend you read the [Tour de Source: NextAuth.js](https://sourcegraph.com/notebooks/Tm90ZWJvb2s6MTc2MQ==) post to become more familiar with the libraries inner workings.
|
||||
|
||||
### Pull Requests
|
||||
|
||||
- The latest changes are always in `main`, so please make your Pull Request against that branch.
|
||||
- Pull Requests should be raised for any change
|
||||
- Pull Requests need approval of a [core contributor](https://next-auth.js.org/contributors#core-team) before merging
|
||||
- We use ESLint/Prettier for linting/formatting, so please run `pnpm lint:fix` before committing to make resolving conflicts easier (VSCode users, check out [this ESLint extension](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) and [this Prettier extension](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode) to fix lint and formatting issues in development)
|
||||
- We encourage you to test your changes, and if you have the opportunity, please make those tests part of the Pull Request
|
||||
- If you add new functionality, please provide the corresponding documentation as well and make it part of the Pull Request
|
||||
|
||||
### Setting up local environment
|
||||
|
||||
|
||||
A quick guide on how to setup _next-auth_ locally to work on it and test out any changes:
|
||||
|
||||
1. Clone the repo:
|
||||
|
||||
```sh
|
||||
git clone git@github.com:nextauthjs/next-auth.git
|
||||
cd next-auth
|
||||
```
|
||||
|
||||
2. Set up the correct pnpm version, using [Corepack](https://nodejs.org/api/corepack.html). Run the following in the project'a root:
|
||||
|
||||
```sh
|
||||
corepack enable pnpm
|
||||
```
|
||||
|
||||
(Now, if you run `pnpm --version`, it should print the same verion as the `packageManager` property in the [`package.json` file](https://github.com/nextauthjs/next-auth/blob/main/package.json))
|
||||
|
||||
3. Install packages. Developing requires Node.js v18:
|
||||
|
||||
```sh
|
||||
pnpm install
|
||||
```
|
||||
|
||||
4. Populate `.env.local`:
|
||||
|
||||
Copy `apps/dev/.env.local.example` to `apps/dev/.env.local`, and add your env variables for each provider you want to test.
|
||||
|
||||
```sh
|
||||
cd apps/dev
|
||||
cp .env.local.example .env.local
|
||||
```
|
||||
|
||||
> NOTE: You can add any environment variables to .env.local that you would like to use in your dev app.
|
||||
> You can find the next-auth config under`apps/dev/pages/api/auth/[...nextauth].js`.
|
||||
|
||||
5. Start the developer application/server:
|
||||
|
||||
```sh
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
Your developer application will be available on `http://localhost:3000`
|
||||
|
||||
That's it! 🎉
|
||||
|
||||
If you need an example project to link to, you can use [next-auth-example](https://github.com/iaincollins/next-auth-example).
|
||||
|
||||
#### Hot reloading
|
||||
|
||||
When running `pnpm dev`, you start a Next.js developer server on `http://localhost:3000`, which includes hot reloading out-of-the-box. Make changes on any of the files in `src` and see the changes immediately.
|
||||
|
||||
> NOTE: When working on CSS, you will have to manually refresh the page after changes. The reason for this is our pages using CSS are server-side rendered (using API routes). (Improving this through a PR is very welcome!)
|
||||
|
||||
> NOTE: The setup is as follows: The development application lives inside the `app` folder, and whenever you make a change to the `src` folder in the root (where next-auth is), it gets copied into `app` every time (gitignored), so Next.js can pick them up and apply hot reloading. This is to avoid some annoying issues with how symlinks are working with different React builds, and also to provide a super-fast feedback loop while developing core features.
|
||||
|
||||
#### Providers
|
||||
|
||||
If you think your custom provider might be useful to others, we encourage you to open a PR and add it to the built-in list so others can discover it much more easily! You only need to add two changes:
|
||||
|
||||
1. Add your config: [`src/providers/{provider}.js`](https://github.com/nextauthjs/next-auth/tree/main/packages/next-auth/src/providers) (Make sure you use a named default export, like `export default function YourProvider`!)
|
||||
2. Add provider documentation: [`www/docs/providers/{provider}.md`](https://github.com/nextauthjs/next-auth/tree/main/www/docs/providers)
|
||||
3. Add provider logo svgs, like `google-dark.svg` (dark mode) and `google.svg` (light mode) to the `/packages/next-auth/provider-logos/` directory. Don't forget to set the provider's styling options in the `provider.style` config object.
|
||||
|
||||
That's it! 🎉 Others will be able to discover this provider much more easily now!
|
||||
|
||||
You can look at the existing built-in providers for inspiration.
|
||||
|
||||
#### Databases
|
||||
|
||||
If you would like to contribute to an existing database adapter or help create a new one, head over to the [nextauthjs/adapters](https://www.github.com/nextauthjs/adapters) repository and follow the instructions provided there.
|
||||
|
||||
#### Testing
|
||||
|
||||
Tests can be run with `pnpm test`.
|
||||
|
||||
Automated tests are currently crude and limited in functionality, but improvements are in development.
|
||||
|
||||
## For maintainers
|
||||
|
||||
We use [a custom script](https://github.com/nextauthjs/next-auth/blob/main/scripts/release/index.ts) together with [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0) to automate releases. This makes the maintenance process easier and less error-prone. Please study the "Conventional Commits" site to understand how to write a good commit message.
|
||||
|
||||
When accepting Pull Requests, make sure the following:
|
||||
|
||||
- Use "Squash and merge"
|
||||
- Make sure you merge contributor PRs into `main`
|
||||
- Rewrite the commit message to conform to the `Conventional Commits` style.
|
||||
- Using `fix` releases a patch (x.x.1)
|
||||
- Using `feat` releases a minor (x.1.x)
|
||||
- Using `feat` when `BREAKING CHANGE` is present in the commit message releases a major (1.x.x)
|
||||
- Optionally link issues the PR will resolve (You can add "close" in front of the issue numbers to close the issues automatically, when the PR is merged. `semantic-release` will also comment back to connected issues and PRs, notifying the users that a feature is added/bug fixed, etc.)
|
||||
|
||||
### Skipping a release
|
||||
|
||||
If a commit contains `[skip release]` in their message, it will be excluded from the commit analysis and won't participate in the release type determination. This is useful, if the PR being merged should not trigger a new `npm` release.
|
||||
24
SECURITY.md
24
SECURITY.md
@@ -1,24 +0,0 @@
|
||||
# Security Policy
|
||||
|
||||
NextAuth.js practices responsible disclosure.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
We request that you contact us directly to report serious issues that might impact the security of sites using NextAuth.js.
|
||||
|
||||
If you contact us regarding a serious issue:
|
||||
|
||||
- We will endeavor to get back to you within 72 hours.
|
||||
- We will aim to publish a fix within 30 days.
|
||||
- We will disclose the issue (and credit you, with your consent) once a fix to resolve the issue has been released.
|
||||
- If 90 days has elapsed and we still don't have a fix, we will disclose the issue publicly.
|
||||
|
||||
The best way to report an issue is by contacting us via email at hi@thvu.dev, info@balazsorban.com, yo@ndo.dev and me@iaincollins.com, or raise a public issue requesting someone get in touch with you via whatever means you prefer for more details. (Please do not disclose sensitive details publicly at this stage.)
|
||||
|
||||
> For less serious issues (e.g. RFC compliance for unsupported flows or potential issues that may cause a problem in the future) it is appropriate to submit these publicly as bug reports or feature requests or to raise a question to open a discussion around them.
|
||||
|
||||
## Supported Versions
|
||||
|
||||
Security updates are only released for the current version.
|
||||
|
||||
Old releases are not maintained and do not receive updates.
|
||||
@@ -23,6 +23,7 @@
|
||||
"faunadb": "^4",
|
||||
"next": "13.0.6",
|
||||
"next-auth": "workspace:*",
|
||||
"@auth/core": "workspace:*",
|
||||
"nodemailer": "^6",
|
||||
"react": "^18",
|
||||
"react-dom": "^18"
|
||||
|
||||
@@ -1,39 +1,39 @@
|
||||
import NextAuth, { type NextAuthOptions } from "next-auth"
|
||||
import { AuthHandler, type AuthOptions } from "@auth/core"
|
||||
|
||||
// Providers
|
||||
import Apple from "next-auth/providers/apple"
|
||||
import Auth0 from "next-auth/providers/auth0"
|
||||
import AzureAD from "next-auth/providers/azure-ad"
|
||||
import AzureB2C from "next-auth/providers/azure-ad-b2c"
|
||||
import BoxyHQSAML from "next-auth/providers/boxyhq-saml"
|
||||
import Cognito from "next-auth/providers/cognito"
|
||||
import Credentials from "next-auth/providers/credentials"
|
||||
import Discord from "next-auth/providers/discord"
|
||||
import DuendeIDS6 from "next-auth/providers/duende-identity-server6"
|
||||
import Email from "next-auth/providers/email"
|
||||
import Facebook from "next-auth/providers/facebook"
|
||||
import Foursquare from "next-auth/providers/foursquare"
|
||||
import Freshbooks from "next-auth/providers/freshbooks"
|
||||
import GitHub from "next-auth/providers/github"
|
||||
import Gitlab from "next-auth/providers/gitlab"
|
||||
import Google from "next-auth/providers/google"
|
||||
import IDS4 from "next-auth/providers/identity-server4"
|
||||
import Instagram from "next-auth/providers/instagram"
|
||||
import Keycloak from "next-auth/providers/keycloak"
|
||||
import Line from "next-auth/providers/line"
|
||||
import LinkedIn from "next-auth/providers/linkedin"
|
||||
import Mailchimp from "next-auth/providers/mailchimp"
|
||||
import Okta from "next-auth/providers/okta"
|
||||
import Osu from "next-auth/providers/osu"
|
||||
import Patreon from "next-auth/providers/patreon"
|
||||
import Slack from "next-auth/providers/slack"
|
||||
import Spotify from "next-auth/providers/spotify"
|
||||
import Trakt from "next-auth/providers/trakt"
|
||||
import Twitch from "next-auth/providers/twitch"
|
||||
import Twitter, { TwitterLegacy } from "next-auth/providers/twitter"
|
||||
import Vk from "next-auth/providers/vk"
|
||||
import Wikimedia from "next-auth/providers/wikimedia"
|
||||
import WorkOS from "next-auth/providers/workos"
|
||||
import Apple from "@auth/core/providers/apple"
|
||||
import Auth0 from "@auth/core/providers/auth0"
|
||||
import AzureAD from "@auth/core/providers/azure-ad"
|
||||
import AzureB2C from "@auth/core/providers/azure-ad-b2c"
|
||||
import BoxyHQSAML from "@auth/core/providers/boxyhq-saml"
|
||||
// import Cognito from "@auth/core/providers/cognito"
|
||||
import Credentials from "@auth/core/providers/credentials"
|
||||
import Discord from "@auth/core/providers/discord"
|
||||
import DuendeIDS6 from "@auth/core/providers/duende-identity-server6"
|
||||
// import Email from "@auth/core/providers/email"
|
||||
import Facebook from "@auth/core/providers/facebook"
|
||||
import Foursquare from "@auth/core/providers/foursquare"
|
||||
import Freshbooks from "@auth/core/providers/freshbooks"
|
||||
import GitHub from "@auth/core/providers/github"
|
||||
import Gitlab from "@auth/core/providers/gitlab"
|
||||
import Google from "@auth/core/providers/google"
|
||||
// import IDS4 from "@auth/core/providers/identity-server4"
|
||||
import Instagram from "@auth/core/providers/instagram"
|
||||
// import Keycloak from "@auth/core/providers/keycloak"
|
||||
import Line from "@auth/core/providers/line"
|
||||
import LinkedIn from "@auth/core/providers/linkedin"
|
||||
import Mailchimp from "@auth/core/providers/mailchimp"
|
||||
// import Okta from "@auth/core/providers/okta"
|
||||
import Osu from "@auth/core/providers/osu"
|
||||
import Patreon from "@auth/core/providers/patreon"
|
||||
import Slack from "@auth/core/providers/slack"
|
||||
import Spotify from "@auth/core/providers/spotify"
|
||||
import Trakt from "@auth/core/providers/trakt"
|
||||
import Twitch from "@auth/core/providers/twitch"
|
||||
import Twitter from "@auth/core/providers/twitter"
|
||||
import Vk from "@auth/core/providers/vk"
|
||||
import Wikimedia from "@auth/core/providers/wikimedia"
|
||||
import WorkOS from "@auth/core/providers/workos"
|
||||
|
||||
// // Prisma
|
||||
// import { PrismaClient } from "@prisma/client"
|
||||
@@ -66,9 +66,9 @@ import WorkOS from "next-auth/providers/workos"
|
||||
// secret: process.env.SUPABASE_SERVICE_ROLE_KEY,
|
||||
// })
|
||||
|
||||
export const authOptions: NextAuthOptions = {
|
||||
export const authOptions: AuthOptions = {
|
||||
// adapter,
|
||||
debug: process.env.NODE_ENV !== "production",
|
||||
// debug: process.env.NODE_ENV !== "production",
|
||||
theme: {
|
||||
logo: "https://next-auth.js.org/img/logo/logo-sm.png",
|
||||
brandColor: "#1786fb",
|
||||
@@ -78,15 +78,19 @@ export const authOptions: NextAuthOptions = {
|
||||
credentials: { password: { label: "Password", type: "password" } },
|
||||
async authorize(credentials) {
|
||||
if (credentials.password !== "pw") return null
|
||||
return { name: "Fill Murray", email: "bill@fillmurray.com", image: "https://www.fillmurray.com/64/64" }
|
||||
return { name: "Fill Murray", email: "bill@fillmurray.com", image: "https://www.fillmurray.com/64/64", id: "1", foo: "" }
|
||||
},
|
||||
}),
|
||||
Apple({ clientId: process.env.APPLE_ID, clientSecret: process.env.APPLE_SECRET }),
|
||||
Auth0({ clientId: process.env.AUTH0_ID, clientSecret: process.env.AUTH0_SECRET, issuer: process.env.AUTH0_ISSUER }),
|
||||
AzureAD({ clientId: process.env.AZURE_AD_CLIENT_ID, clientSecret: process.env.AZURE_AD_CLIENT_SECRET, tenantId: process.env.AZURE_AD_TENANT_ID }),
|
||||
AzureAD({
|
||||
clientId: process.env.AZURE_AD_CLIENT_ID,
|
||||
clientSecret: process.env.AZURE_AD_CLIENT_SECRET,
|
||||
tenantId: process.env.AZURE_AD_TENANT_ID,
|
||||
}),
|
||||
AzureB2C({ clientId: process.env.AZURE_B2C_ID, clientSecret: process.env.AZURE_B2C_SECRET, issuer: process.env.AZURE_B2C_ISSUER }),
|
||||
BoxyHQSAML({ issuer: "https://jackson-demo.boxyhq.com", clientId: "tenant=boxyhq.com&product=saml-demo.boxyhq.com", clientSecret: "dummy" }),
|
||||
Cognito({ clientId: process.env.COGNITO_ID, clientSecret: process.env.COGNITO_SECRET, issuer: process.env.COGNITO_ISSUER }),
|
||||
// Cognito({ clientId: process.env.COGNITO_ID, clientSecret: process.env.COGNITO_SECRET, issuer: process.env.COGNITO_ISSUER }),
|
||||
Discord({ clientId: process.env.DISCORD_ID, clientSecret: process.env.DISCORD_SECRET }),
|
||||
DuendeIDS6({ clientId: "interactive.confidential", clientSecret: "secret", issuer: "https://demo.duendesoftware.com" }),
|
||||
Facebook({ clientId: process.env.FACEBOOK_ID, clientSecret: process.env.FACEBOOK_SECRET }),
|
||||
@@ -95,21 +99,21 @@ export const authOptions: NextAuthOptions = {
|
||||
GitHub({ clientId: process.env.GITHUB_ID, clientSecret: process.env.GITHUB_SECRET }),
|
||||
Gitlab({ clientId: process.env.GITLAB_ID, clientSecret: process.env.GITLAB_SECRET }),
|
||||
Google({ clientId: process.env.GOOGLE_ID, clientSecret: process.env.GOOGLE_SECRET }),
|
||||
IDS4({ clientId: process.env.IDS4_ID, clientSecret: process.env.IDS4_SECRET, issuer: process.env.IDS4_ISSUER }),
|
||||
// IDS4({ clientId: process.env.IDS4_ID, clientSecret: process.env.IDS4_SECRET, issuer: process.env.IDS4_ISSUER }),
|
||||
Instagram({ clientId: process.env.INSTAGRAM_ID, clientSecret: process.env.INSTAGRAM_SECRET }),
|
||||
Keycloak({ clientId: process.env.KEYCLOAK_ID, clientSecret: process.env.KEYCLOAK_SECRET, issuer: process.env.KEYCLOAK_ISSUER }),
|
||||
// Keycloak({ clientId: process.env.KEYCLOAK_ID, clientSecret: process.env.KEYCLOAK_SECRET, issuer: process.env.KEYCLOAK_ISSUER }),
|
||||
Line({ clientId: process.env.LINE_ID, clientSecret: process.env.LINE_SECRET }),
|
||||
LinkedIn({ clientId: process.env.LINKEDIN_ID, clientSecret: process.env.LINKEDIN_SECRET }),
|
||||
Mailchimp({ clientId: process.env.MAILCHIMP_ID, clientSecret: process.env.MAILCHIMP_SECRET }),
|
||||
Okta({ clientId: process.env.OKTA_ID, clientSecret: process.env.OKTA_SECRET, issuer: process.env.OKTA_ISSUER }),
|
||||
// Okta({ clientId: process.env.OKTA_ID, clientSecret: process.env.OKTA_SECRET, issuer: process.env.OKTA_ISSUER }),
|
||||
Osu({ clientId: process.env.OSU_CLIENT_ID, clientSecret: process.env.OSU_CLIENT_SECRET }),
|
||||
Patreon({ clientId: process.env.PATREON_ID, clientSecret: process.env.PATREON_SECRET }),
|
||||
Slack({ clientId: process.env.SLACK_ID, clientSecret: process.env.SLACK_SECRET }),
|
||||
Spotify({ clientId: process.env.SPOTIFY_ID, clientSecret: process.env.SPOTIFY_SECRET }),
|
||||
Trakt({ clientId: process.env.TRAKT_ID, clientSecret: process.env.TRAKT_SECRET }),
|
||||
Twitch({ clientId: process.env.TWITCH_ID, clientSecret: process.env.TWITCH_SECRET }),
|
||||
Twitter({ version: "2.0", clientId: process.env.TWITTER_ID, clientSecret: process.env.TWITTER_SECRET }),
|
||||
TwitterLegacy({ clientId: process.env.TWITTER_LEGACY_ID, clientSecret: process.env.TWITTER_LEGACY_SECRET }),
|
||||
Twitter({ clientId: process.env.TWITTER_ID, clientSecret: process.env.TWITTER_SECRET }),
|
||||
// TwitterLegacy({ clientId: process.env.TWITTER_LEGACY_ID, clientSecret: process.env.TWITTER_LEGACY_SECRET }),
|
||||
Vk({ clientId: process.env.VK_ID, clientSecret: process.env.VK_SECRET }),
|
||||
Wikimedia({ clientId: process.env.WIKIMEDIA_ID, clientSecret: process.env.WIKIMEDIA_SECRET }),
|
||||
WorkOS({ clientId: process.env.WORKOS_ID, clientSecret: process.env.WORKOS_SECRET }),
|
||||
@@ -117,11 +121,34 @@ export const authOptions: NextAuthOptions = {
|
||||
}
|
||||
|
||||
if (authOptions.adapter) {
|
||||
authOptions.providers.unshift(
|
||||
// NOTE: You can start a fake e-mail server with `pnpm email`
|
||||
// and then go to `http://localhost:1080` in the browser
|
||||
Email({ server: "smtp://127.0.0.1:1025?tls.rejectUnauthorized=false" })
|
||||
)
|
||||
// TODO:
|
||||
// authOptions.providers.unshift(
|
||||
// // NOTE: You can start a fake e-mail server with `pnpm email`
|
||||
// // and then go to `http://localhost:1080` in the browser
|
||||
// Email({ server: "smtp://127.0.0.1:1025?tls.rejectUnauthorized=false" })
|
||||
// )
|
||||
}
|
||||
|
||||
export default NextAuth(authOptions)
|
||||
// TODO: move to next-auth/edge
|
||||
function Auth(...args: any[]) {
|
||||
const envSecret = process.env.AUTH_SECRET ?? process.env.NEXTAUTH_SECRET
|
||||
const envTrustHost = !!(process.env.NEXTAUTH_URL ?? process.env.AUTH_TRUST_HOST ?? process.env.VERCEL ?? process.env.NODE_ENV !== "production")
|
||||
if (args.length === 1) {
|
||||
return async (req: Request) => {
|
||||
args[0].secret ??= envSecret
|
||||
args[0].trustHost ??= envTrustHost
|
||||
return await AuthHandler(req, args[0])
|
||||
}
|
||||
}
|
||||
args[1].secret ??= envSecret
|
||||
args[1].trustHost ??= envTrustHost
|
||||
return AuthHandler(args[0], args[1])
|
||||
}
|
||||
|
||||
// export default Auth(authOptions)
|
||||
|
||||
export default function handle(request: Request) {
|
||||
return Auth(request, authOptions)
|
||||
}
|
||||
|
||||
export const config = { runtime: "experimental-edge" }
|
||||
|
||||
13
apps/playground-sveltekit/.eslintignore
Normal file
13
apps/playground-sveltekit/.eslintignore
Normal file
@@ -0,0 +1,13 @@
|
||||
.DS_Store
|
||||
node_modules
|
||||
/build
|
||||
/.svelte-kit
|
||||
/package
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Ignore files for PNPM, NPM and YARN
|
||||
pnpm-lock.yaml
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
@@ -1,24 +1,20 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
parser: "@typescript-eslint/parser",
|
||||
extends: [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"prettier",
|
||||
],
|
||||
plugins: ["svelte3", "@typescript-eslint"],
|
||||
ignorePatterns: ["*.cjs", "build/**/*"],
|
||||
overrides: [{ files: ["*.svelte"], processor: "svelte3/svelte3" }],
|
||||
settings: {
|
||||
"svelte3/typescript": () => require("typescript"),
|
||||
},
|
||||
parserOptions: {
|
||||
sourceType: "module",
|
||||
ecmaVersion: 2020,
|
||||
},
|
||||
env: {
|
||||
browser: true,
|
||||
es2017: true,
|
||||
node: true,
|
||||
},
|
||||
}
|
||||
root: true,
|
||||
parser: '@typescript-eslint/parser',
|
||||
extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'prettier'],
|
||||
plugins: ['svelte3', '@typescript-eslint'],
|
||||
ignorePatterns: ['*.cjs'],
|
||||
overrides: [{ files: ['*.svelte'], processor: 'svelte3/svelte3' }],
|
||||
settings: {
|
||||
'svelte3/typescript': () => require('typescript')
|
||||
},
|
||||
parserOptions: {
|
||||
sourceType: 'module',
|
||||
ecmaVersion: 2020
|
||||
},
|
||||
env: {
|
||||
browser: true,
|
||||
es2017: true,
|
||||
node: true
|
||||
}
|
||||
};
|
||||
|
||||
4
apps/playground-sveltekit/.gitignore
vendored
4
apps/playground-sveltekit/.gitignore
vendored
@@ -6,3 +6,7 @@ node_modules
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.vercel
|
||||
.output
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
|
||||
13
apps/playground-sveltekit/.prettierignore
Normal file
13
apps/playground-sveltekit/.prettierignore
Normal file
@@ -0,0 +1,13 @@
|
||||
.DS_Store
|
||||
node_modules
|
||||
/build
|
||||
/.svelte-kit
|
||||
/package
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Ignore files for PNPM, NPM and YARN
|
||||
pnpm-lock.yaml
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
9
apps/playground-sveltekit/.prettierrc
Normal file
9
apps/playground-sveltekit/.prettierrc
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"useTabs": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 100,
|
||||
"plugins": ["prettier-plugin-svelte"],
|
||||
"pluginSearchDirs": ["."],
|
||||
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
|
||||
}
|
||||
@@ -1,43 +1,37 @@
|
||||
{
|
||||
"name": "sveltekit-nextauth",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"start": "HOST=127.0.0.1 PORT=5173 ORIGIN=http://localhost:5173 node ./build",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"lint": "prettier --check . && eslint .",
|
||||
"format": "prettier --write ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^1.0.0-next.80",
|
||||
"@sveltejs/adapter-node": "1.0.0-next.96",
|
||||
"@sveltejs/kit": "1.0.0-next.511",
|
||||
"@types/cookie": "^0.5.1",
|
||||
"@typescript-eslint/eslint-plugin": "^5.35.1",
|
||||
"@typescript-eslint/parser": "^5.35.1",
|
||||
"eslint": "^8.22.0",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
"eslint-plugin-svelte3": "^4.0.0",
|
||||
"prettier": "^2.7.1",
|
||||
"prettier-plugin-svelte": "^2.7.0",
|
||||
"svelte": "^3.49.0",
|
||||
"svelte-check": "^2.8.1",
|
||||
"svelte-preprocess": "^4.10.7",
|
||||
"tslib": "^2.4.0",
|
||||
"typescript": "~4.8.2",
|
||||
"vite": "^3.1.0"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"cookie": "0.5.0",
|
||||
"next-auth": "latest"
|
||||
},
|
||||
"prettier": {
|
||||
"semi": false,
|
||||
"singleQuote": false
|
||||
}
|
||||
"name": "sveltekit-nextauth",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@fontsource/fira-mono": "^4.5.10",
|
||||
"@neoconfetti/svelte": "^1.0.0",
|
||||
"@sveltejs/adapter-auto": "next",
|
||||
"@sveltejs/kit": "next",
|
||||
"@types/cookie": "^0.5.1",
|
||||
"@typescript-eslint/eslint-plugin": "^5.45.0",
|
||||
"@typescript-eslint/parser": "^5.45.0",
|
||||
"eslint": "^8.28.0",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
"eslint-plugin-svelte3": "^4.0.0",
|
||||
"prettier": "^2.8.0",
|
||||
"prettier-plugin-svelte": "^2.8.1",
|
||||
"svelte": "^3.54.0",
|
||||
"svelte-check": "^2.9.2",
|
||||
"tslib": "^2.4.1",
|
||||
"typescript": "^4.9.3",
|
||||
"vite": "^4.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"cookie": "0.5.0",
|
||||
"@auth/core": "workspace:*",
|
||||
"@auth/sveltekit": "workspace:^"
|
||||
},
|
||||
"type": "module"
|
||||
}
|
||||
|
||||
4
apps/playground-sveltekit/src/app.d.ts
vendored
4
apps/playground-sveltekit/src/app.d.ts
vendored
@@ -1,4 +1,5 @@
|
||||
/// <reference types="@sveltejs/kit" />
|
||||
/// <reference types="next-auth-sveltekit" />
|
||||
import type {
|
||||
User as NextAuthUser,
|
||||
Session as NextAuthSession,
|
||||
@@ -18,7 +19,8 @@ interface AppSession extends NextAuthSession {
|
||||
declare global {
|
||||
declare namespace App {
|
||||
interface Locals {
|
||||
session: AppSession
|
||||
// session: AppSession
|
||||
getSession: () => Promise<AppSession>
|
||||
}
|
||||
|
||||
interface Platform {}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body>
|
||||
<div>%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div>%sveltekit.body%</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -1,14 +1,25 @@
|
||||
import type { Handle } from "@sveltejs/kit"
|
||||
import { getServerSession, options as nextAuthOptions } from "$lib/next-auth"
|
||||
import SvelteKitAuth from "@auth/sveltekit"
|
||||
import GitHub from '@auth/core/providers/github';
|
||||
import Google from '@auth/core/providers/google';
|
||||
import Credentials from '@auth/core/providers/credentials';
|
||||
import {
|
||||
GITHUB_CLIENT_ID,
|
||||
GITHUB_CLIENT_SECRET,
|
||||
GOOGLE_CLIENT_ID,
|
||||
GOOGLE_CLIENT_SECRET,
|
||||
} from "$env/static/private"
|
||||
|
||||
export const handle: Handle = async function handle({
|
||||
event,
|
||||
resolve,
|
||||
}): Promise<Response> {
|
||||
const session = await getServerSession(event.request, nextAuthOptions)
|
||||
if (session) {
|
||||
event.locals.session = session
|
||||
}
|
||||
|
||||
return resolve(event)
|
||||
}
|
||||
export const handle = SvelteKitAuth({
|
||||
providers: [
|
||||
GitHub({ clientId: GITHUB_CLIENT_ID, clientSecret: GITHUB_CLIENT_SECRET }),
|
||||
Google({ clientId: GOOGLE_CLIENT_ID, clientSecret: GOOGLE_CLIENT_SECRET }),
|
||||
Credentials({
|
||||
credentials: { password: { label: "Password", type: "password" } },
|
||||
async authorize(credentials) {
|
||||
if (credentials.password !== "pw") return null
|
||||
return { name: "Fill Murray", email: "bill@fillmurray.com", image: "https://www.fillmurray.com/64/64", id: "1", foo: "" }
|
||||
},
|
||||
}),
|
||||
],
|
||||
debug: true,
|
||||
});
|
||||
|
||||
12
apps/playground-sveltekit/src/lib/SignInButton.svelte
Normal file
12
apps/playground-sveltekit/src/lib/SignInButton.svelte
Normal file
@@ -0,0 +1,12 @@
|
||||
<script lang="ts">
|
||||
export let provider: any;
|
||||
</script>
|
||||
|
||||
<form action={provider.signinUrl} method="POST">
|
||||
{#if provider.callbackUrl}
|
||||
<input type="hidden" name="callbackUrl" value={provider.callbackUrl} />
|
||||
{/if}
|
||||
<button type="submit" class="button">
|
||||
<slot>Sign in with {provider.name}</slot>
|
||||
</button>
|
||||
</form>
|
||||
@@ -1,144 +0,0 @@
|
||||
import type { ServerLoadEvent } from "@sveltejs/kit"
|
||||
import type { RequestInternal } from "next-auth"
|
||||
import type { NextAuthAction, NextAuthOptions } from "next-auth/core/types"
|
||||
import type { OutgoingResponse as NextAuthResponse } from "next-auth/core"
|
||||
import { NextAuthHandler } from "next-auth/core"
|
||||
import GithubProvider from "next-auth/providers/github"
|
||||
import cookie from "cookie"
|
||||
import {
|
||||
GITHUB_CLIENT_ID,
|
||||
GITHUB_CLIENT_SECRET,
|
||||
NEXTAUTH_SECRET,
|
||||
} from "$env/static/private"
|
||||
import { PUBLIC_NEXTAUTH_URL } from "$env/static/public"
|
||||
|
||||
// @ts-expect-error import is exported on .default during SSR
|
||||
const github = GithubProvider?.default || GithubProvider
|
||||
|
||||
export const options: NextAuthOptions = {
|
||||
providers: [
|
||||
github({
|
||||
clientId: GITHUB_CLIENT_ID,
|
||||
clientSecret: GITHUB_CLIENT_SECRET,
|
||||
}),
|
||||
],
|
||||
}
|
||||
|
||||
const toSvelteKitResponse = async <
|
||||
T extends string | any[] | Record<string, any>
|
||||
>(
|
||||
request: Request,
|
||||
nextAuthResponse: NextAuthResponse<T>
|
||||
): Promise<Response> => {
|
||||
const { cookies, redirect } = nextAuthResponse
|
||||
|
||||
const headers = new Headers()
|
||||
for (const header of nextAuthResponse?.headers || []) {
|
||||
// pass headers along from next-auth
|
||||
headers.set(header.key, header.value)
|
||||
}
|
||||
|
||||
// set-cookie header
|
||||
if (cookies?.length) {
|
||||
headers.set(
|
||||
"set-cookie",
|
||||
cookies
|
||||
?.map((item) => cookie.serialize(item.name, item.value, item.options))
|
||||
.join(",") as string
|
||||
)
|
||||
}
|
||||
|
||||
let body = undefined
|
||||
let status = nextAuthResponse.status || 200
|
||||
|
||||
if (redirect) {
|
||||
let formData: FormData | null = null
|
||||
try {
|
||||
formData = await request.formData()
|
||||
} catch {
|
||||
// no formData passed
|
||||
}
|
||||
const { json } = Object.fromEntries(formData ?? [])
|
||||
if (json !== "true") {
|
||||
status = 302
|
||||
headers.set("Location", redirect)
|
||||
} else {
|
||||
body = { url: redirect }
|
||||
}
|
||||
} else {
|
||||
body = nextAuthResponse.body
|
||||
}
|
||||
|
||||
// @ts-expect-error - body is a known HTML document or JSON object
|
||||
return new Response(body, {
|
||||
status,
|
||||
headers,
|
||||
})
|
||||
}
|
||||
|
||||
const SKNextAuthHandler = async (
|
||||
{ request, url, params }: ServerLoadEvent,
|
||||
options: NextAuthOptions
|
||||
): Promise<Response> => {
|
||||
const [action, provider] = params.nextauth!.split("/")
|
||||
let body: FormData | undefined
|
||||
try {
|
||||
body = await request.formData()
|
||||
} catch {
|
||||
// no formData passed
|
||||
}
|
||||
options.secret = NEXTAUTH_SECRET
|
||||
const req: RequestInternal = {
|
||||
host: PUBLIC_NEXTAUTH_URL,
|
||||
body: Object.fromEntries(body ?? []),
|
||||
query: Object.fromEntries(url.searchParams),
|
||||
headers: request.headers,
|
||||
method: request.method,
|
||||
cookies: cookie.parse(request.headers.get("cookie") || ""),
|
||||
action: action as NextAuthAction,
|
||||
providerId: provider,
|
||||
error: provider,
|
||||
}
|
||||
|
||||
const response = await NextAuthHandler({
|
||||
req,
|
||||
options,
|
||||
})
|
||||
|
||||
return toSvelteKitResponse(request, response)
|
||||
}
|
||||
|
||||
export const getServerSession = async (
|
||||
request: Request,
|
||||
options: NextAuthOptions
|
||||
): Promise<App.Session | null> => {
|
||||
options.secret = NEXTAUTH_SECRET
|
||||
|
||||
const session = await NextAuthHandler<App.Session>({
|
||||
req: {
|
||||
host: PUBLIC_NEXTAUTH_URL,
|
||||
action: "session",
|
||||
method: "GET",
|
||||
cookies: cookie.parse(request.headers.get("cookie") || ""),
|
||||
headers: request.headers,
|
||||
},
|
||||
options,
|
||||
})
|
||||
|
||||
const { body } = session
|
||||
|
||||
if (body && Object.keys(body).length) {
|
||||
return body as App.Session
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export const NextAuth = (
|
||||
options: NextAuthOptions
|
||||
): {
|
||||
GET: (event: ServerLoadEvent) => Promise<unknown>
|
||||
POST: (event: ServerLoadEvent) => Promise<unknown>
|
||||
} => ({
|
||||
GET: (event) => SKNextAuthHandler(event, options),
|
||||
POST: (event) => SKNextAuthHandler(event, options),
|
||||
})
|
||||
@@ -1,7 +1,14 @@
|
||||
import type { LayoutServerLoad } from "./$types"
|
||||
|
||||
export const load: LayoutServerLoad = ({ locals }) => {
|
||||
export const load: LayoutServerLoad = (event) => {
|
||||
console.log('layout server load', event.locals.getSession)
|
||||
let session
|
||||
if (event.locals.getSession)
|
||||
{
|
||||
session = event.locals.getSession()
|
||||
|
||||
}
|
||||
return {
|
||||
session: locals.session,
|
||||
session,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,151 +1,144 @@
|
||||
<script lang="ts">
|
||||
import { page } from "$app/stores"
|
||||
import { page } from '$app/stores';
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<header>
|
||||
<div class="signedInStatus">
|
||||
<p class="nojs-show loaded">
|
||||
{#if Object.keys($page.data.session || {}).length}
|
||||
{#if $page.data.session.user.image}
|
||||
<span
|
||||
style="background-image: url('{$page.data.session.user.image}')"
|
||||
class="avatar"
|
||||
/>
|
||||
{/if}
|
||||
<span class="signedInText">
|
||||
<small>Signed in as</small><br />
|
||||
<strong
|
||||
>{$page.data.session.user.email ||
|
||||
$page.data.session.user.name}</strong
|
||||
>
|
||||
</span>
|
||||
<a href="/api/auth/signout" class="button">Sign out</a>
|
||||
{:else}
|
||||
<span class="notSignedInText">You are not signed in</span>
|
||||
<a href="/api/auth/signin" class="buttonPrimary">Sign in</a>
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
<nav>
|
||||
<ul class="navItems">
|
||||
<li class="navItem"><a href="/">Home</a></li>
|
||||
<li class="navItem"><a href="/protected">Protected</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
</header>
|
||||
<slot />
|
||||
<header>
|
||||
<div class="signedInStatus">
|
||||
<p class="nojs-show loaded">
|
||||
{#if Object.keys($page.data.session || {}).length}
|
||||
{#if $page.data.session.user.image}
|
||||
<span style="background-image: url('{$page.data.session.user.image}')" class="avatar" />
|
||||
{/if}
|
||||
<span class="signedInText">
|
||||
<small>Signed in as</small><br />
|
||||
<strong>{$page.data.session.user.email || $page.data.session.user.name}</strong>
|
||||
</span>
|
||||
<a href="/auth/signout" class="button">Sign out</a>
|
||||
{:else}
|
||||
<span class="notSignedInText">You are not signed in</span>
|
||||
<a href="/auth/signin" class="buttonPrimary">Sign in</a>
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
<nav>
|
||||
<ul class="navItems">
|
||||
<li class="navItem"><a href="/">Home</a></li>
|
||||
<li class="navItem"><a href="/protected">Protected</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
</header>
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
:global(body) {
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
|
||||
"Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif,
|
||||
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
|
||||
"Noto Color Emoji";
|
||||
padding: 0 1rem 1rem 1rem;
|
||||
max-width: 680px;
|
||||
margin: 0 auto;
|
||||
background: #fff;
|
||||
color: #333;
|
||||
}
|
||||
:global(li),
|
||||
:global(p) {
|
||||
line-height: 1.5rem;
|
||||
}
|
||||
:global(a) {
|
||||
font-weight: 500;
|
||||
}
|
||||
:global(hr) {
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
:global(iframe) {
|
||||
background: #ccc;
|
||||
border: 1px solid #ccc;
|
||||
height: 10rem;
|
||||
width: 100%;
|
||||
border-radius: 0.5rem;
|
||||
filter: invert(1);
|
||||
}
|
||||
:global(body) {
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
|
||||
'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
|
||||
'Segoe UI Symbol', 'Noto Color Emoji';
|
||||
padding: 0 1rem 1rem 1rem;
|
||||
max-width: 680px;
|
||||
margin: 0 auto;
|
||||
background: #fff;
|
||||
color: #333;
|
||||
}
|
||||
:global(li),
|
||||
:global(p) {
|
||||
line-height: 1.5rem;
|
||||
}
|
||||
:global(a) {
|
||||
font-weight: 500;
|
||||
}
|
||||
:global(hr) {
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
:global(iframe) {
|
||||
background: #ccc;
|
||||
border: 1px solid #ccc;
|
||||
height: 10rem;
|
||||
width: 100%;
|
||||
border-radius: 0.5rem;
|
||||
filter: invert(1);
|
||||
}
|
||||
|
||||
.nojs-show {
|
||||
opacity: 1;
|
||||
top: 0;
|
||||
}
|
||||
.signedInStatus {
|
||||
display: block;
|
||||
min-height: 4rem;
|
||||
width: 100%;
|
||||
}
|
||||
.loaded {
|
||||
position: relative;
|
||||
top: 0;
|
||||
opacity: 1;
|
||||
overflow: hidden;
|
||||
border-radius: 0 0 0.6rem 0.6rem;
|
||||
padding: 0.6rem 1rem;
|
||||
margin: 0;
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
transition: all 0.2s ease-in;
|
||||
}
|
||||
.signedInText,
|
||||
.notSignedInText {
|
||||
position: absolute;
|
||||
padding-top: 0.8rem;
|
||||
left: 1rem;
|
||||
right: 6.5rem;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
display: inherit;
|
||||
z-index: 1;
|
||||
line-height: 1.3rem;
|
||||
}
|
||||
.signedInText {
|
||||
padding-top: 0rem;
|
||||
left: 4.6rem;
|
||||
}
|
||||
.avatar {
|
||||
border-radius: 2rem;
|
||||
float: left;
|
||||
height: 2.8rem;
|
||||
width: 2.8rem;
|
||||
background-color: white;
|
||||
background-size: cover;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
.button,
|
||||
.buttonPrimary {
|
||||
float: right;
|
||||
margin-right: -0.4rem;
|
||||
font-weight: 500;
|
||||
border-radius: 0.3rem;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
line-height: 1.4rem;
|
||||
padding: 0.7rem 0.8rem;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
background-color: transparent;
|
||||
color: #555;
|
||||
}
|
||||
.buttonPrimary {
|
||||
background-color: #346df1;
|
||||
border-color: #346df1;
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
padding: 0.7rem 1.4rem;
|
||||
}
|
||||
.buttonPrimary:hover {
|
||||
box-shadow: inset 0 0 5rem rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
.navItems {
|
||||
margin-bottom: 2rem;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.navItem {
|
||||
display: inline-block;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
.nojs-show {
|
||||
opacity: 1;
|
||||
top: 0;
|
||||
}
|
||||
.signedInStatus {
|
||||
display: block;
|
||||
min-height: 4rem;
|
||||
width: 100%;
|
||||
}
|
||||
.loaded {
|
||||
position: relative;
|
||||
top: 0;
|
||||
opacity: 1;
|
||||
overflow: hidden;
|
||||
border-radius: 0 0 0.6rem 0.6rem;
|
||||
padding: 0.6rem 1rem;
|
||||
margin: 0;
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
transition: all 0.2s ease-in;
|
||||
}
|
||||
.signedInText,
|
||||
.notSignedInText {
|
||||
position: absolute;
|
||||
padding-top: 0.8rem;
|
||||
left: 1rem;
|
||||
right: 6.5rem;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
display: inherit;
|
||||
z-index: 1;
|
||||
line-height: 1.3rem;
|
||||
}
|
||||
.signedInText {
|
||||
padding-top: 0rem;
|
||||
left: 4.6rem;
|
||||
}
|
||||
.avatar {
|
||||
border-radius: 2rem;
|
||||
float: left;
|
||||
height: 2.8rem;
|
||||
width: 2.8rem;
|
||||
background-color: white;
|
||||
background-size: cover;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
.button,
|
||||
.buttonPrimary {
|
||||
float: right;
|
||||
margin-right: -0.4rem;
|
||||
font-weight: 500;
|
||||
border-radius: 0.3rem;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
line-height: 1.4rem;
|
||||
padding: 0.7rem 0.8rem;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
background-color: transparent;
|
||||
color: #555;
|
||||
}
|
||||
.buttonPrimary {
|
||||
background-color: #346df1;
|
||||
border-color: #346df1;
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
padding: 0.7rem 1.4rem;
|
||||
}
|
||||
.buttonPrimary:hover {
|
||||
box-shadow: inset 0 0 5rem rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
.navItems {
|
||||
margin-bottom: 2rem;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.navItem {
|
||||
display: inline-block;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,25 @@
|
||||
<script>
|
||||
import { signIn, signOut } from '@auth/sveltekit/client';
|
||||
import { page } from '$app/stores';
|
||||
</script>
|
||||
|
||||
<h1>SvelteKit + NextAuth.js Example</h1>
|
||||
<p>
|
||||
This is an example site to demonstrate how to use <a
|
||||
href="https://kit.svelte.dev/">SvelteKit</a
|
||||
>
|
||||
with <a href="https://next-auth.js.org">NextAuth.js</a> for authentication.
|
||||
This is an example site to demonstrate how to use <a href="https://kit.svelte.dev/">SvelteKit</a>
|
||||
with <a href="https://next-auth.js.org">NextAuth.js</a> for authentication.
|
||||
|
||||
{#if Object.keys($page.data.session || {}).length}
|
||||
{#if $page.data.session.user.image}
|
||||
<span style="background-image: url('{$page.data.session.user.image}')" class="avatar" />
|
||||
{/if}
|
||||
<span class="signedInText">
|
||||
<small>Signed in as</small><br />
|
||||
<strong>{$page.data.session.user.email || $page.data.session.user.name}</strong>
|
||||
</span>
|
||||
<button on:click={() => signOut()} class="button">Sign out</button>
|
||||
{:else}
|
||||
<span class="notSignedInText">You are not signed in</span>
|
||||
<button on:click={() => signIn('github')}>Sign In with GitHub</button>
|
||||
<button on:click={() => signIn('credentials', { redirect: false })}>Sign In credentials</button>
|
||||
{/if}
|
||||
</p>
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
import { NextAuth, options } from "$lib/next-auth"
|
||||
|
||||
export const { GET, POST } = NextAuth(options)
|
||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
@@ -1,14 +1,15 @@
|
||||
import adapter from "@sveltejs/adapter-node" // or use https://github.com/sveltejs/kit/tree/master/packages/adapter-auto
|
||||
import preprocess from "svelte-preprocess"
|
||||
import adapter from '@sveltejs/adapter-auto';
|
||||
import { vitePreprocess } from '@sveltejs/kit/vite';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
// Consult https://github.com/sveltejs/svelte-preprocess
|
||||
// for more information about preprocessors
|
||||
preprocess: preprocess(),
|
||||
kit: {
|
||||
adapter: adapter(),
|
||||
},
|
||||
}
|
||||
// Consult https://kit.svelte.dev/docs/integrations#preprocessors
|
||||
// for more information about preprocessors
|
||||
preprocess: vitePreprocess(),
|
||||
|
||||
export default config
|
||||
kit: {
|
||||
adapter: adapter()
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true
|
||||
}
|
||||
// Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias
|
||||
//
|
||||
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
|
||||
// from the referenced tsconfig.json - TypeScript does not merge them in
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true
|
||||
}
|
||||
// Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias
|
||||
//
|
||||
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
|
||||
// from the referenced tsconfig.json - TypeScript does not merge them in
|
||||
}
|
||||
|
||||
8
apps/playground-sveltekit/vite.config.js
Normal file
8
apps/playground-sveltekit/vite.config.js
Normal file
@@ -0,0 +1,8 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
|
||||
/** @type {import('vite').UserConfig} */
|
||||
const config = {
|
||||
plugins: [sveltekit()]
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -1,8 +0,0 @@
|
||||
import { sveltekit } from "@sveltejs/kit/vite"
|
||||
import type { UserConfig } from "vite"
|
||||
|
||||
const config: UserConfig = {
|
||||
plugins: [sveltekit()],
|
||||
}
|
||||
|
||||
export default config
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,76 +0,0 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
In the interest of fostering an open and welcoming environment, we as
|
||||
contributors and maintainers pledge to making participation in our project and
|
||||
our community a harassment-free experience for everyone, regardless of age, body
|
||||
size, disability, ethnicity, sex characteristics, gender identity and expression,
|
||||
level of experience, education, socio-economic status, nationality, personal
|
||||
appearance, race, religion, or sexual identity and orientation.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to creating a positive environment
|
||||
include:
|
||||
|
||||
- Using welcoming and inclusive language
|
||||
- Being respectful of differing viewpoints and experiences
|
||||
- Gracefully accepting constructive criticism
|
||||
- Focusing on what is best for the community
|
||||
- Showing empathy towards other community members
|
||||
|
||||
Examples of unacceptable behavior by participants include:
|
||||
|
||||
- The use of sexualized language or imagery and unwelcome sexual attention or
|
||||
advances
|
||||
- Trolling, insulting/derogatory comments, and personal or political attacks
|
||||
- Public or private harassment
|
||||
- Publishing others' private information, such as a physical or electronic
|
||||
address, without explicit permission
|
||||
- Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Our Responsibilities
|
||||
|
||||
Project maintainers are responsible for clarifying the standards of acceptable
|
||||
behavior and are expected to take appropriate and fair corrective action in
|
||||
response to any instances of unacceptable behavior.
|
||||
|
||||
Project maintainers have the right and responsibility to remove, edit, or
|
||||
reject comments, commits, code, wiki edits, issues, and other contributions
|
||||
that are not aligned to this Code of Conduct, or to ban temporarily or
|
||||
permanently any contributor for other behaviors that they deem inappropriate,
|
||||
threatening, offensive, or harmful.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies both within project spaces and in public spaces
|
||||
when an individual is representing the project or its community. Examples of
|
||||
representing a project or community include using an official project e-mail
|
||||
address, posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event. Representation of a project may be
|
||||
further defined and clarified by project maintainers.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported by contacting me@iaincollins.com. All complaints will be reviewed and
|
||||
investigated and will result in a response that is deemed necessary and
|
||||
appropriate to the circumstances. The project team is obligated to maintain
|
||||
confidentiality with regard to the reporter of an incident. Further details of
|
||||
specific enforcement policies may be posted separately.
|
||||
|
||||
Project maintainers who do not follow or enforce the Code of Conduct in good
|
||||
faith may face temporary or permanent repercussions as determined by other
|
||||
members of the project's leadership.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
|
||||
available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
For answers to common questions about this code of conduct, see
|
||||
https://www.contributor-covenant.org/faq
|
||||
@@ -1,26 +0,0 @@
|
||||
# Contributing guide
|
||||
|
||||
Contributions and feedback on your experience of using this software are welcome.
|
||||
|
||||
This includes bug reports, feature requests, ideas, pull requests, and examples of how you have used this software.
|
||||
|
||||
Please see the [Code of Conduct](CODE_OF_CONDUCT.md) and follow any templates configured in GitHub when reporting bugs, requesting enhancements, or contributing code.
|
||||
|
||||
Please raise any significant new functionality or breaking change an issue for discussion before raising a Pull Request for it.
|
||||
|
||||
## For contributors
|
||||
|
||||
Anyone can be a contributor. Either you found a typo, or you have an awesome feature request you could implement, we encourage you to create a Pull Request.
|
||||
|
||||
### Pull Requests
|
||||
|
||||
- The latest changes are always in `main`, so please make your Pull Request against that branch.
|
||||
- Pull Requests should be raised for any change
|
||||
|
||||
### Setting up local environment
|
||||
|
||||
The local environment can be setup by doing the following.
|
||||
|
||||
1. Clone the repository `$ git clone https://github.com/nextauthjs/docs.git`
|
||||
2. Install dependencies `$ npm install`
|
||||
3. Start development server `$ npm start`
|
||||
15
package.json
15
package.json
@@ -11,6 +11,7 @@
|
||||
"clean": "turbo run clean --no-cache",
|
||||
"dev:db": "turbo run dev --parallel --continue --filter=next-auth-app...",
|
||||
"dev": "turbo run dev --parallel --continue --filter=next-auth-app... --filter=!./packages/adapter-*",
|
||||
"dev:kit": "turbo run dev --parallel --continue --filter=sveltekit-nextauth...",
|
||||
"dev:docs": "turbo run dev --filter=next-auth-docs",
|
||||
"email": "cd apps/dev && pnpm email",
|
||||
"release": "release",
|
||||
@@ -18,7 +19,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@actions/core": "^1.6.0",
|
||||
"@balazsorban/monorepo-release": "0.0.5",
|
||||
"@balazsorban/monorepo-release": "0.1.0",
|
||||
"@types/jest": "^28.1.3",
|
||||
"@types/node": "^17.0.25",
|
||||
"@typescript-eslint/eslint-plugin": "^5.10.2",
|
||||
@@ -39,6 +40,11 @@
|
||||
"turbo": "1.3.1",
|
||||
"typescript": "4.8.4"
|
||||
},
|
||||
"release": {
|
||||
"packageDirectories": [
|
||||
"packages"
|
||||
]
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.19.0 || ^14.15.0 || ^16.13.0 || ^18.12.0"
|
||||
},
|
||||
@@ -64,5 +70,10 @@
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/nextauth"
|
||||
}
|
||||
]
|
||||
],
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"undici": "5.11.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
3
packages/core/README.md
Normal file
3
packages/core/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Auth.js
|
||||
|
||||
Authentication for the web.
|
||||
75
packages/core/package.json
Normal file
75
packages/core/package.json
Normal file
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"name": "@auth/core",
|
||||
"version": "0.1.1",
|
||||
"description": "Authentication for the web.",
|
||||
"homepage": "https://next-auth.js.org",
|
||||
"repository": "https://github.com/nextauthjs/next-auth.git",
|
||||
"author": "Balázs Orbán <info@balazsorban.com>",
|
||||
"contributors": [
|
||||
"Balázs Orbán <info@balazsorban.com>",
|
||||
"Nico Domino <yo@ndo.dev>",
|
||||
"Lluis Agusti <hi@llu.lu>",
|
||||
"Thang Huu Vu <thvu@hey.com>",
|
||||
"Iain Collins <me@iaincollins.com"
|
||||
],
|
||||
"type": "module",
|
||||
"types": "./index.d.ts",
|
||||
"files": [
|
||||
"adapters.*",
|
||||
"index.*",
|
||||
"jwt",
|
||||
"lib",
|
||||
"providers",
|
||||
"src"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./index.d.ts",
|
||||
"import": "./index.js"
|
||||
},
|
||||
"./adapters": {
|
||||
"types": "./adapters.d.ts"
|
||||
},
|
||||
"./jwt": {
|
||||
"types": "./jwt/index.d.ts",
|
||||
"import": "./jwt/index.js"
|
||||
},
|
||||
"./providers/*": {
|
||||
"types": "./providers/*.d.ts",
|
||||
"import": "./providers/*.js"
|
||||
}
|
||||
},
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@panva/hkdf": "1.0.2",
|
||||
"cookie": "0.5.0",
|
||||
"jose": "4.11.1",
|
||||
"oauth4webapi": "2.0.4",
|
||||
"preact": "10.11.3",
|
||||
"preact-render-to-string": "5.2.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"nodemailer": "6.8.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"nodemailer": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "pnpm clean && tsc && pnpm css",
|
||||
"clean": "rm -rf adapters.* index.* jwt lib providers",
|
||||
"css": "node ./scripts/generate-css.js",
|
||||
"dev": "pnpm css && tsc -w"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@next-auth/tsconfig": "workspace:*",
|
||||
"@types/node": "18.11.10",
|
||||
"@types/nodemailer": "6.4.6",
|
||||
"@types/react": "18.0.26",
|
||||
"autoprefixer": "10.4.13",
|
||||
"cssnano": "5.1.14",
|
||||
"postcss": "8.4.19",
|
||||
"postcss-nested": "6.0.0"
|
||||
}
|
||||
}
|
||||
22
packages/core/scripts/generate-css.js
Normal file
22
packages/core/scripts/generate-css.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import postcss from "postcss"
|
||||
|
||||
import autoprefixer from "autoprefixer"
|
||||
import postCssNested from "postcss-nested"
|
||||
import cssNano from "cssnano"
|
||||
|
||||
const from = path.join(process.cwd(), "src/lib/styles/index.css")
|
||||
const css = fs.readFileSync(from)
|
||||
|
||||
const processedCss = await postcss([
|
||||
autoprefixer,
|
||||
postCssNested,
|
||||
cssNano({ preset: "default" }),
|
||||
]).process(css, { from })
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(process.cwd(), "src/lib/styles/index.ts"),
|
||||
`export default \`${processedCss.css}\`
|
||||
// Generated by \`pnpm css\``
|
||||
)
|
||||
130
packages/core/src/adapters.ts
Normal file
130
packages/core/src/adapters.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import type { Account, Awaitable, User } from "."
|
||||
|
||||
export interface AdapterUser extends User {
|
||||
id: string
|
||||
email: string
|
||||
emailVerified: Date | null
|
||||
}
|
||||
|
||||
export interface AdapterAccount extends Account {
|
||||
userId: string
|
||||
}
|
||||
|
||||
export interface AdapterSession {
|
||||
/** A randomly generated value that is used to get hold of the session. */
|
||||
sessionToken: string
|
||||
/** Used to connect the session to a particular user */
|
||||
userId: string
|
||||
expires: Date
|
||||
}
|
||||
|
||||
export interface VerificationToken {
|
||||
identifier: string
|
||||
expires: Date
|
||||
token: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Using a custom adapter you can connect to any database backend or even several different databases.
|
||||
* Custom adapters created and maintained by our community can be found in the adapters repository.
|
||||
* Feel free to add a custom adapter from your project to the repository,
|
||||
* or even become a maintainer of a certain adapter.
|
||||
* Custom adapters can still be created and used in a project without being added to the repository.
|
||||
*
|
||||
* **Required methods**
|
||||
*
|
||||
* _(These methods are required for all sign in flows)_
|
||||
* - `createUser`
|
||||
* - `getUser`
|
||||
* - `getUserByEmail`
|
||||
* - `getUserByAccount`
|
||||
* - `linkAccount`
|
||||
* - `createSession`
|
||||
* - `getSessionAndUser`
|
||||
* - `updateSession`
|
||||
* - `deleteSession`
|
||||
* - `updateUser`
|
||||
*
|
||||
* _(Required to support email / passwordless sign in)_
|
||||
*
|
||||
* - `createVerificationToken`
|
||||
* - `useVerificationToken`
|
||||
*
|
||||
* **Unimplemented methods**
|
||||
*
|
||||
* _(These methods will be required in a future release, but are not yet invoked)_
|
||||
* - `deleteUser`
|
||||
* - `unlinkAccount`
|
||||
*
|
||||
* [Adapters Overview](https://next-auth.js.org/adapters/overview) |
|
||||
* [Create a custom adapter](https://next-auth.js.org/tutorials/creating-a-database-adapter)
|
||||
*/
|
||||
export type Adapter<WithVerificationToken = boolean> = DefaultAdapter &
|
||||
(WithVerificationToken extends true
|
||||
? {
|
||||
createVerificationToken: (
|
||||
verificationToken: VerificationToken
|
||||
) => Awaitable<VerificationToken | null | undefined>
|
||||
/**
|
||||
* Return verification token from the database
|
||||
* and delete it so it cannot be used again.
|
||||
*/
|
||||
useVerificationToken: (params: {
|
||||
identifier: string
|
||||
token: string
|
||||
}) => Awaitable<VerificationToken | null>
|
||||
}
|
||||
: {})
|
||||
|
||||
export interface DefaultAdapter {
|
||||
createUser: (user: Omit<AdapterUser, "id">) => Awaitable<AdapterUser>
|
||||
getUser: (id: string) => Awaitable<AdapterUser | null>
|
||||
getUserByEmail: (email: string) => Awaitable<AdapterUser | null>
|
||||
/** Using the provider id and the id of the user for a specific account, get the user. */
|
||||
getUserByAccount: (
|
||||
providerAccountId: Pick<AdapterAccount, "provider" | "providerAccountId">
|
||||
) => Awaitable<AdapterUser | null>
|
||||
updateUser: (user: Partial<AdapterUser>) => Awaitable<AdapterUser>
|
||||
/** @todo Implement */
|
||||
deleteUser?: (
|
||||
userId: string
|
||||
) => Promise<void> | Awaitable<AdapterUser | null | undefined>
|
||||
linkAccount: (
|
||||
account: AdapterAccount
|
||||
) => Promise<void> | Awaitable<AdapterAccount | null | undefined>
|
||||
/** @todo Implement */
|
||||
unlinkAccount?: (
|
||||
providerAccountId: Pick<AdapterAccount, "provider" | "providerAccountId">
|
||||
) => Promise<void> | Awaitable<AdapterAccount | undefined>
|
||||
/** Creates a session for the user and returns it. */
|
||||
createSession: (session: {
|
||||
sessionToken: string
|
||||
userId: string
|
||||
expires: Date
|
||||
}) => Awaitable<AdapterSession>
|
||||
getSessionAndUser: (
|
||||
sessionToken: string
|
||||
) => Awaitable<{ session: AdapterSession; user: AdapterUser } | null>
|
||||
updateSession: (
|
||||
session: Partial<AdapterSession> & Pick<AdapterSession, "sessionToken">
|
||||
) => Awaitable<AdapterSession | null | undefined>
|
||||
/**
|
||||
* Deletes a session from the database.
|
||||
* It is preferred that this method also returns the session
|
||||
* that is being deleted for logging purposes.
|
||||
*/
|
||||
deleteSession: (
|
||||
sessionToken: string
|
||||
) => Promise<void> | Awaitable<AdapterSession | null | undefined>
|
||||
createVerificationToken?: (
|
||||
verificationToken: VerificationToken
|
||||
) => Awaitable<VerificationToken | null | undefined>
|
||||
/**
|
||||
* Return verification token from the database
|
||||
* and delete it so it cannot be used again.
|
||||
*/
|
||||
useVerificationToken?: (params: {
|
||||
identifier: string
|
||||
token: string
|
||||
}) => Awaitable<VerificationToken | null>
|
||||
}
|
||||
287
packages/core/src/index.ts
Normal file
287
packages/core/src/index.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
import { init } from "./lib/init"
|
||||
import { assertConfig } from "./lib/assert"
|
||||
import { SessionStore } from "./lib/cookie"
|
||||
import { toInternalRequest, toResponse } from "./lib/web"
|
||||
import renderPage from "./lib/pages"
|
||||
import * as routes from "./lib/routes"
|
||||
import logger, { setLogger } from "./lib/utils/logger"
|
||||
|
||||
import type { ErrorType } from "./lib/pages/error"
|
||||
import type {
|
||||
AuthOptions,
|
||||
RequestInternal,
|
||||
ResponseInternal,
|
||||
} from "./lib/types"
|
||||
import { UntrustedHost } from "./lib/errors"
|
||||
|
||||
export * from "./lib/types"
|
||||
|
||||
const configErrorMessage =
|
||||
"There is a problem with the server configuration. Check the server logs for more information."
|
||||
|
||||
async function AuthHandlerInternal<
|
||||
Body extends string | Record<string, any> | any[]
|
||||
>(params: {
|
||||
req: RequestInternal
|
||||
options: AuthOptions
|
||||
/** REVIEW: Is this the best way to skip parsing the body in Node.js? */
|
||||
parsedBody?: any
|
||||
}): Promise<ResponseInternal<Body>> {
|
||||
const { options: authOptions, req } = params
|
||||
|
||||
const assertionResult = assertConfig({ options: authOptions, req })
|
||||
|
||||
if (Array.isArray(assertionResult)) {
|
||||
assertionResult.forEach(logger.warn)
|
||||
} else if (assertionResult instanceof Error) {
|
||||
// Bail out early if there's an error in the user config
|
||||
logger.error(assertionResult.code, assertionResult)
|
||||
|
||||
const htmlPages = ["signin", "signout", "error", "verify-request"]
|
||||
if (!htmlPages.includes(req.action) || req.method !== "GET") {
|
||||
return {
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: { message: configErrorMessage } as any,
|
||||
}
|
||||
}
|
||||
const { pages, theme } = authOptions
|
||||
|
||||
const authOnErrorPage =
|
||||
pages?.error && req.query?.callbackUrl?.startsWith(pages.error)
|
||||
|
||||
if (!pages?.error || authOnErrorPage) {
|
||||
if (authOnErrorPage) {
|
||||
logger.error(
|
||||
"AUTH_ON_ERROR_PAGE_ERROR",
|
||||
new Error(
|
||||
`The error page ${pages?.error} should not require authentication`
|
||||
)
|
||||
)
|
||||
}
|
||||
const render = renderPage({ theme })
|
||||
return render.error({ error: "configuration" })
|
||||
}
|
||||
|
||||
return {
|
||||
redirect: `${pages.error}?error=Configuration`,
|
||||
}
|
||||
}
|
||||
|
||||
const { action, providerId, error, method } = req
|
||||
|
||||
const { options, cookies } = await init({
|
||||
authOptions,
|
||||
action,
|
||||
providerId,
|
||||
url: req.url,
|
||||
callbackUrl: req.body?.callbackUrl ?? req.query?.callbackUrl,
|
||||
csrfToken: req.body?.csrfToken,
|
||||
cookies: req.cookies,
|
||||
isPost: method === "POST",
|
||||
})
|
||||
|
||||
const sessionStore = new SessionStore(
|
||||
options.cookies.sessionToken,
|
||||
req,
|
||||
options.logger
|
||||
)
|
||||
|
||||
if (method === "GET") {
|
||||
const render = renderPage({ ...options, query: req.query, cookies })
|
||||
const { pages } = options
|
||||
switch (action) {
|
||||
case "providers":
|
||||
return (await routes.providers(options.providers)) as any
|
||||
case "session": {
|
||||
const session = await routes.session({ options, sessionStore })
|
||||
if (session.cookies) cookies.push(...session.cookies)
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
|
||||
return { ...session, cookies } as any
|
||||
}
|
||||
case "csrf":
|
||||
return {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: { csrfToken: options.csrfToken } as any,
|
||||
cookies,
|
||||
}
|
||||
case "signin":
|
||||
if (pages.signIn) {
|
||||
let signinUrl = `${pages.signIn}${
|
||||
pages.signIn.includes("?") ? "&" : "?"
|
||||
}callbackUrl=${encodeURIComponent(options.callbackUrl)}`
|
||||
if (error)
|
||||
signinUrl = `${signinUrl}&error=${encodeURIComponent(error)}`
|
||||
return { redirect: signinUrl, cookies }
|
||||
}
|
||||
|
||||
return render.signin()
|
||||
case "signout":
|
||||
if (pages.signOut) return { redirect: pages.signOut, cookies }
|
||||
|
||||
return render.signout()
|
||||
case "callback":
|
||||
if (options.provider) {
|
||||
const callback = await routes.callback({
|
||||
body: req.body,
|
||||
query: req.query,
|
||||
headers: req.headers,
|
||||
cookies: req.cookies,
|
||||
method,
|
||||
options,
|
||||
sessionStore,
|
||||
})
|
||||
if (callback.cookies) cookies.push(...callback.cookies)
|
||||
return { ...callback, cookies }
|
||||
}
|
||||
break
|
||||
case "verify-request":
|
||||
if (pages.verifyRequest) {
|
||||
return { redirect: pages.verifyRequest, cookies }
|
||||
}
|
||||
return render.verifyRequest()
|
||||
case "error":
|
||||
// These error messages are displayed in line on the sign in page
|
||||
if (
|
||||
[
|
||||
"Signin",
|
||||
"OAuthSignin",
|
||||
"OAuthCallback",
|
||||
"OAuthCreateAccount",
|
||||
"EmailCreateAccount",
|
||||
"Callback",
|
||||
"OAuthAccountNotLinked",
|
||||
"EmailSignin",
|
||||
"CredentialsSignin",
|
||||
"SessionRequired",
|
||||
].includes(error as string)
|
||||
) {
|
||||
return { redirect: `${options.url}/signin?error=${error}`, cookies }
|
||||
}
|
||||
|
||||
if (pages.error) {
|
||||
return {
|
||||
redirect: `${pages.error}${
|
||||
pages.error.includes("?") ? "&" : "?"
|
||||
}error=${error}`,
|
||||
cookies,
|
||||
}
|
||||
}
|
||||
|
||||
return render.error({ error: error as ErrorType })
|
||||
default:
|
||||
}
|
||||
} else if (method === "POST") {
|
||||
switch (action) {
|
||||
case "signin":
|
||||
// Verified CSRF Token required for all sign in routes
|
||||
if (options.csrfTokenVerified && options.provider) {
|
||||
const signin = await routes.signin({
|
||||
query: req.query,
|
||||
body: req.body,
|
||||
options,
|
||||
})
|
||||
if (signin.cookies) cookies.push(...signin.cookies)
|
||||
return { ...signin, cookies }
|
||||
}
|
||||
|
||||
return { redirect: `${options.url}/signin?csrf=true`, cookies }
|
||||
case "signout":
|
||||
// Verified CSRF Token required for signout
|
||||
if (options.csrfTokenVerified) {
|
||||
const signout = await routes.signout({ options, sessionStore })
|
||||
if (signout.cookies) cookies.push(...signout.cookies)
|
||||
return { ...signout, cookies }
|
||||
}
|
||||
return { redirect: `${options.url}/signout?csrf=true`, cookies }
|
||||
case "callback":
|
||||
if (options.provider) {
|
||||
// Verified CSRF Token required for credentials providers only
|
||||
if (
|
||||
options.provider.type === "credentials" &&
|
||||
!options.csrfTokenVerified
|
||||
) {
|
||||
return { redirect: `${options.url}/signin?csrf=true`, cookies }
|
||||
}
|
||||
|
||||
const callback = await routes.callback({
|
||||
body: req.body,
|
||||
query: req.query,
|
||||
headers: req.headers,
|
||||
cookies: req.cookies,
|
||||
method,
|
||||
options,
|
||||
sessionStore,
|
||||
})
|
||||
if (callback.cookies) cookies.push(...callback.cookies)
|
||||
return { ...callback, cookies }
|
||||
}
|
||||
break
|
||||
case "_log":
|
||||
if (authOptions.logger) {
|
||||
try {
|
||||
const { code, level, ...metadata } = req.body ?? {}
|
||||
logger[level](code, metadata)
|
||||
} catch (error) {
|
||||
// If logging itself failed...
|
||||
logger.error("LOGGER_ERROR", error as Error)
|
||||
}
|
||||
}
|
||||
return {}
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: 400,
|
||||
body: `Error: This action with HTTP ${method} is not supported by NextAuth.js` as any,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The core functionality of Auth.js.
|
||||
* It receives a standard [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request)
|
||||
* and returns a standard [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response).
|
||||
*/
|
||||
export async function AuthHandler(
|
||||
request: Request,
|
||||
options: AuthOptions
|
||||
): Promise<Response> {
|
||||
setLogger(options.logger, options.debug)
|
||||
|
||||
if (!options.trustHost) {
|
||||
const error = new UntrustedHost(
|
||||
`Host must be trusted. URL was: ${request.url}`
|
||||
)
|
||||
logger.error(error.code, error)
|
||||
|
||||
return new Response(JSON.stringify({ message: configErrorMessage }), {
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
}
|
||||
|
||||
const req = await toInternalRequest(request)
|
||||
if (req instanceof Error) {
|
||||
logger.error((req as any).code, req)
|
||||
return new Response(
|
||||
`Error: This action with HTTP ${request.method} is not supported.`,
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
const internalResponse = await AuthHandlerInternal({ req, options })
|
||||
|
||||
const response = await toResponse(internalResponse)
|
||||
|
||||
// If the request expects a return URL, send it as JSON
|
||||
// instead of doing an actual redirect.
|
||||
const redirect = response.headers.get("Location")
|
||||
if (request.headers.has("X-Auth-Return-Redirect") && redirect) {
|
||||
response.headers.delete("Location")
|
||||
response.headers.set("Content-Type", "application/json")
|
||||
return new Response(JSON.stringify({ url: redirect }), {
|
||||
headers: response.headers,
|
||||
})
|
||||
}
|
||||
return response
|
||||
}
|
||||
127
packages/core/src/jwt/index.ts
Normal file
127
packages/core/src/jwt/index.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { EncryptJWT, jwtDecrypt } from "jose"
|
||||
import hkdf from "@panva/hkdf"
|
||||
import { SessionStore } from "../lib/cookie"
|
||||
import type { JWT, JWTDecodeParams, JWTEncodeParams, JWTOptions } from "./types"
|
||||
import type { LoggerInstance } from ".."
|
||||
|
||||
export * from "./types"
|
||||
|
||||
const DEFAULT_MAX_AGE = 30 * 24 * 60 * 60 // 30 days
|
||||
|
||||
const now = () => (Date.now() / 1000) | 0
|
||||
|
||||
/** Issues a JWT. By default, the JWT is encrypted using "A256GCM". */
|
||||
export async function encode(params: JWTEncodeParams) {
|
||||
const { token = {}, secret, maxAge = DEFAULT_MAX_AGE } = params
|
||||
const encryptionSecret = await getDerivedEncryptionKey(secret)
|
||||
return await new EncryptJWT(token)
|
||||
.setProtectedHeader({ alg: "dir", enc: "A256GCM" })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime(now() + maxAge)
|
||||
.setJti(crypto.randomUUID())
|
||||
.encrypt(encryptionSecret)
|
||||
}
|
||||
|
||||
/** Decodes a NextAuth.js issued JWT. */
|
||||
export async function decode(params: JWTDecodeParams): Promise<JWT | null> {
|
||||
const { token, secret } = params
|
||||
if (!token) return null
|
||||
const encryptionSecret = await getDerivedEncryptionKey(secret)
|
||||
const { payload } = await jwtDecrypt(token, encryptionSecret, {
|
||||
clockTolerance: 15,
|
||||
})
|
||||
return payload
|
||||
}
|
||||
|
||||
export interface GetTokenParams<R extends boolean = false> {
|
||||
/** The request containing the JWT either in the cookies or in the `Authorization` header. */
|
||||
req:
|
||||
| Request
|
||||
| { cookies: Record<string, string>; headers: Record<string, string> }
|
||||
/**
|
||||
* Use secure prefix for cookie name, unless URL in `NEXTAUTH_URL` is http://
|
||||
* or not set (e.g. development or test instance) case use unprefixed name
|
||||
*/
|
||||
secureCookie?: boolean
|
||||
/** If the JWT is in the cookie, what name `getToken()` should look for. */
|
||||
cookieName?: string
|
||||
/**
|
||||
* `getToken()` will return the raw JWT if this is set to `true`
|
||||
* @default false
|
||||
*/
|
||||
raw?: R
|
||||
/**
|
||||
* The same `secret` used in the `NextAuth` configuration.
|
||||
* Defaults to the `NEXTAUTH_SECRET` environment variable.
|
||||
*/
|
||||
secret?: string
|
||||
decode?: JWTOptions["decode"]
|
||||
logger?: LoggerInstance | Console
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a NextAuth.js request (`req`) and returns either the NextAuth.js issued JWT's payload,
|
||||
* or the raw JWT string. We look for the JWT in the either the cookies, or the `Authorization` header.
|
||||
* [Documentation](https://next-auth.js.org/tutorials/securing-pages-and-api-routes#using-gettoken)
|
||||
*/
|
||||
export async function getToken<R extends boolean = false>(
|
||||
params: GetTokenParams<R>
|
||||
): Promise<R extends true ? string : JWT | null> {
|
||||
const {
|
||||
req,
|
||||
secureCookie = process.env.NEXTAUTH_URL?.startsWith("https://") ??
|
||||
!!process.env.VERCEL,
|
||||
cookieName = secureCookie
|
||||
? "__Secure-next-auth.session-token"
|
||||
: "next-auth.session-token",
|
||||
raw,
|
||||
decode: _decode = decode,
|
||||
logger = console,
|
||||
secret = process.env.NEXTAUTH_SECRET,
|
||||
} = params
|
||||
|
||||
if (!req) throw new Error("Must pass `req` to JWT getToken()")
|
||||
|
||||
const sessionStore = new SessionStore(
|
||||
{ name: cookieName, options: { secure: secureCookie } },
|
||||
// @ts-expect-error
|
||||
{ cookies: req.cookies, headers: req.headers },
|
||||
logger
|
||||
)
|
||||
|
||||
let token = sessionStore.value
|
||||
|
||||
const authorizationHeader =
|
||||
req.headers instanceof Headers
|
||||
? req.headers.get("authorization")
|
||||
: req.headers.authorization
|
||||
|
||||
if (!token && authorizationHeader?.split(" ")[0] === "Bearer") {
|
||||
const urlEncodedToken = authorizationHeader.split(" ")[1]
|
||||
token = decodeURIComponent(urlEncodedToken)
|
||||
}
|
||||
|
||||
// @ts-expect-error
|
||||
if (!token) return null
|
||||
|
||||
// @ts-expect-error
|
||||
if (raw) return token
|
||||
|
||||
try {
|
||||
// @ts-expect-error
|
||||
return await _decode({ token, secret })
|
||||
} catch {
|
||||
// @ts-expect-error
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function getDerivedEncryptionKey(secret: string | Buffer) {
|
||||
return await hkdf(
|
||||
"sha256",
|
||||
secret,
|
||||
"",
|
||||
"NextAuth.js Generated Encryption Key",
|
||||
32
|
||||
)
|
||||
}
|
||||
54
packages/core/src/jwt/types.ts
Normal file
54
packages/core/src/jwt/types.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import type { Awaitable } from ".."
|
||||
|
||||
export interface DefaultJWT extends Record<string, unknown> {
|
||||
name?: string | null
|
||||
email?: string | null
|
||||
picture?: string | null
|
||||
sub?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Returned by the `jwt` callback and `getToken`, when using JWT sessions
|
||||
*
|
||||
* [`jwt` callback](https://next-auth.js.org/configuration/callbacks#jwt-callback) | [`getToken`](https://next-auth.js.org/tutorials/securing-pages-and-api-routes#using-gettoken)
|
||||
*/
|
||||
export interface JWT extends Record<string, unknown>, DefaultJWT {}
|
||||
|
||||
export interface JWTEncodeParams {
|
||||
/** The JWT payload. */
|
||||
token?: JWT
|
||||
/** The secret used to encode the NextAuth.js issued JWT. */
|
||||
secret: string | Buffer
|
||||
/**
|
||||
* The maximum age of the NextAuth.js issued JWT in seconds.
|
||||
* @default 30 * 24 * 30 * 60 // 30 days
|
||||
*/
|
||||
maxAge?: number
|
||||
}
|
||||
|
||||
export interface JWTDecodeParams {
|
||||
/** The NextAuth.js issued JWT to be decoded */
|
||||
token?: string
|
||||
/** The secret used to decode the NextAuth.js issued JWT. */
|
||||
secret: string | Buffer
|
||||
}
|
||||
|
||||
export interface JWTOptions {
|
||||
/**
|
||||
* The secret used to encode/decode the NextAuth.js issued JWT.
|
||||
* @deprecated Set the `NEXTAUTH_SECRET` environment vairable or
|
||||
* use the top-level `secret` option instead
|
||||
*/
|
||||
secret: string
|
||||
/**
|
||||
* The maximum age of the NextAuth.js issued JWT in seconds.
|
||||
* @default 30 * 24 * 30 * 60 // 30 days
|
||||
*/
|
||||
maxAge: number
|
||||
/** Override this method to control the NextAuth.js issued JWT encoding. */
|
||||
encode: (params: JWTEncodeParams) => Awaitable<string>
|
||||
/** Override this method to control the NextAuth.js issued JWT decoding. */
|
||||
decode: (params: JWTDecodeParams) => Awaitable<JWT | null>
|
||||
}
|
||||
|
||||
export type Secret = string | Buffer
|
||||
157
packages/core/src/lib/assert.ts
Normal file
157
packages/core/src/lib/assert.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import {
|
||||
InvalidCallbackUrl,
|
||||
InvalidEndpoints,
|
||||
MissingAdapter,
|
||||
MissingAdapterMethods,
|
||||
MissingAPIRoute,
|
||||
MissingAuthorize,
|
||||
MissingSecret,
|
||||
UnsupportedStrategy,
|
||||
} from "./errors"
|
||||
import { defaultCookies } from "./cookie"
|
||||
|
||||
import type { AuthOptions, RequestInternal } from ".."
|
||||
import type { WarningCode } from "./utils/logger"
|
||||
|
||||
type ConfigError =
|
||||
| MissingAdapter
|
||||
| MissingAdapterMethods
|
||||
| MissingAPIRoute
|
||||
| MissingAuthorize
|
||||
| MissingSecret
|
||||
| InvalidCallbackUrl
|
||||
| UnsupportedStrategy
|
||||
| InvalidEndpoints
|
||||
| UnsupportedStrategy
|
||||
|
||||
let warned = false
|
||||
|
||||
function isValidHttpUrl(url: string, baseUrl: string) {
|
||||
try {
|
||||
return /^https?:/.test(
|
||||
new URL(url, url.startsWith("/") ? baseUrl : undefined).protocol
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify that the user configured Auth.js correctly.
|
||||
* Good place to mention deprecations as well.
|
||||
*
|
||||
* REVIEW: Make some of these and corresponding docs less Next.js specific?
|
||||
*/
|
||||
export function assertConfig(params: {
|
||||
options: AuthOptions
|
||||
req: RequestInternal
|
||||
}): ConfigError | WarningCode[] {
|
||||
const { options, req } = params
|
||||
const { url } = req
|
||||
const warnings: WarningCode[] = []
|
||||
|
||||
if (!warned) {
|
||||
if (!url.origin) warnings.push("NEXTAUTH_URL")
|
||||
if (options.debug) warnings.push("DEBUG_ENABLED")
|
||||
}
|
||||
|
||||
if (!options.secret) {
|
||||
return new MissingSecret("Please define a `secret` in production.")
|
||||
}
|
||||
|
||||
// req.query isn't defined when asserting `unstable_getServerSession` for example
|
||||
if (!req.query?.nextauth && !req.action) {
|
||||
return new MissingAPIRoute(
|
||||
"Cannot find [...nextauth].{js,ts} in `/pages/api/auth`. Make sure the filename is written correctly."
|
||||
)
|
||||
}
|
||||
|
||||
const callbackUrlParam = req.query?.callbackUrl as string | undefined
|
||||
|
||||
if (callbackUrlParam && !isValidHttpUrl(callbackUrlParam, url.origin)) {
|
||||
return new InvalidCallbackUrl(
|
||||
`Invalid callback URL. Received: ${callbackUrlParam}`
|
||||
)
|
||||
}
|
||||
|
||||
const { callbackUrl: defaultCallbackUrl } = defaultCookies(
|
||||
options.useSecureCookies ?? url.protocol === "https://"
|
||||
)
|
||||
const callbackUrlCookie =
|
||||
req.cookies?.[options.cookies?.callbackUrl?.name ?? defaultCallbackUrl.name]
|
||||
|
||||
if (callbackUrlCookie && !isValidHttpUrl(callbackUrlCookie, url.origin)) {
|
||||
return new InvalidCallbackUrl(
|
||||
`Invalid callback URL. Received: ${callbackUrlCookie}`
|
||||
)
|
||||
}
|
||||
|
||||
let hasCredentials, hasEmail
|
||||
|
||||
for (const provider of options.providers) {
|
||||
if (
|
||||
(provider.type === "oauth" || provider.type === "oidc") &&
|
||||
!(provider.issuer ?? provider.options?.issuer)
|
||||
) {
|
||||
const { authorization: a, token: t, userinfo: u } = provider
|
||||
|
||||
let key
|
||||
if (typeof a !== "string" && !a?.url) key = "authorization"
|
||||
else if (typeof t !== "string" && !t?.url) key = "token"
|
||||
else if (typeof u !== "string" && !u?.url) key = "userinfo"
|
||||
|
||||
if (key) {
|
||||
return new InvalidEndpoints(
|
||||
`Provider "${provider.id}" is missing both \`issuer\` and \`${key}\` endpoint config. At least one of them is required.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (provider.type === "credentials") hasCredentials = true
|
||||
else if (provider.type === "email") hasEmail = true
|
||||
}
|
||||
|
||||
if (hasCredentials) {
|
||||
const dbStrategy = options.session?.strategy === "database"
|
||||
const onlyCredentials = !options.providers.some(
|
||||
(p) => p.type !== "credentials"
|
||||
)
|
||||
if (dbStrategy && onlyCredentials) {
|
||||
return new UnsupportedStrategy(
|
||||
"Signin in with credentials only supported if JWT strategy is enabled"
|
||||
)
|
||||
}
|
||||
|
||||
const credentialsNoAuthorize = options.providers.some(
|
||||
(p) => p.type === "credentials" && !p.authorize
|
||||
)
|
||||
if (credentialsNoAuthorize) {
|
||||
return new MissingAuthorize(
|
||||
"Must define an authorize() handler to use credentials authentication provider"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (hasEmail) {
|
||||
const { adapter } = options
|
||||
if (!adapter) {
|
||||
return new MissingAdapter("E-mail login requires an adapter.")
|
||||
}
|
||||
|
||||
const missingMethods = [
|
||||
"createVerificationToken",
|
||||
"useVerificationToken",
|
||||
"getUserByEmail",
|
||||
].filter((method) => !adapter[method])
|
||||
|
||||
if (missingMethods.length) {
|
||||
return new MissingAdapterMethods(
|
||||
`Required adapter methods were missing: ${missingMethods.join(", ")}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (!warned) warned = true
|
||||
|
||||
return warnings
|
||||
}
|
||||
228
packages/core/src/lib/callback-handler.ts
Normal file
228
packages/core/src/lib/callback-handler.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
import { AccountNotLinkedError } from "./errors"
|
||||
import { fromDate } from "./utils/date"
|
||||
|
||||
import type { Account, InternalOptions, User } from ".."
|
||||
import type { AdapterSession, AdapterUser } from "../adapters"
|
||||
import type { JWT } from "../jwt"
|
||||
import type { OAuthConfig } from "../providers"
|
||||
import type { SessionToken } from "./cookie"
|
||||
|
||||
/**
|
||||
* This function handles the complex flow of signing users in, and either creating,
|
||||
* linking (or not linking) accounts depending on if the user is currently logged
|
||||
* in, if they have account already and the authentication mechanism they are using.
|
||||
*
|
||||
* It prevents insecure behaviour, such as linking OAuth accounts unless a user is
|
||||
* signed in and authenticated with an existing valid account.
|
||||
*
|
||||
* All verification (e.g. OAuth flows or email address verificaiton flows) are
|
||||
* done prior to this handler being called to avoid additonal complexity in this
|
||||
* handler.
|
||||
*/
|
||||
export default async function callbackHandler(params: {
|
||||
sessionToken?: SessionToken
|
||||
profile: User | AdapterUser | { email: string }
|
||||
account: Account | null
|
||||
options: InternalOptions
|
||||
}) {
|
||||
const { sessionToken, profile: _profile, account, options } = params
|
||||
// Input validation
|
||||
if (!account?.providerAccountId || !account.type)
|
||||
throw new Error("Missing or invalid provider account")
|
||||
if (!["email", "oauth", "oidc"].includes(account.type))
|
||||
throw new Error("Provider not supported")
|
||||
|
||||
const {
|
||||
adapter,
|
||||
jwt,
|
||||
events,
|
||||
session: { strategy: sessionStrategy, generateSessionToken },
|
||||
} = options
|
||||
|
||||
// If no adapter is configured then we don't have a database and cannot
|
||||
// persist data; in this mode we just return a dummy session object.
|
||||
if (!adapter) {
|
||||
return { user: _profile as User, account }
|
||||
}
|
||||
|
||||
const profile = _profile as AdapterUser
|
||||
|
||||
const {
|
||||
createUser,
|
||||
updateUser,
|
||||
getUser,
|
||||
getUserByAccount,
|
||||
getUserByEmail,
|
||||
linkAccount,
|
||||
createSession,
|
||||
getSessionAndUser,
|
||||
deleteSession,
|
||||
} = adapter
|
||||
|
||||
let session: AdapterSession | JWT | null = null
|
||||
let user: AdapterUser | null = null
|
||||
let isNewUser = false
|
||||
|
||||
const useJwtSession = sessionStrategy === "jwt"
|
||||
|
||||
if (sessionToken) {
|
||||
if (useJwtSession) {
|
||||
try {
|
||||
session = await jwt.decode({ ...jwt, token: sessionToken })
|
||||
if (session && "sub" in session && session.sub) {
|
||||
user = await getUser(session.sub)
|
||||
}
|
||||
} catch {
|
||||
// If session can't be verified, treat as no session
|
||||
}
|
||||
} else {
|
||||
const userAndSession = await getSessionAndUser(sessionToken)
|
||||
if (userAndSession) {
|
||||
session = userAndSession.session
|
||||
user = userAndSession.user
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (account.type === "email") {
|
||||
// If signing in with an email, check if an account with the same email address exists already
|
||||
const userByEmail = await getUserByEmail(profile.email)
|
||||
if (userByEmail) {
|
||||
// If they are not already signed in as the same user, this flow will
|
||||
// sign them out of the current session and sign them in as the new user
|
||||
if (user?.id !== userByEmail.id && !useJwtSession && sessionToken) {
|
||||
// Delete existing session if they are currently signed in as another user.
|
||||
// This will switch user accounts for the session in cases where the user was
|
||||
// already logged in with a different account.
|
||||
await deleteSession(sessionToken)
|
||||
}
|
||||
|
||||
// Update emailVerified property on the user object
|
||||
user = await updateUser({ id: userByEmail.id, emailVerified: new Date() })
|
||||
await events.updateUser?.({ user })
|
||||
} else {
|
||||
const { id: _, ...newUser } = { ...profile, emailVerified: new Date() }
|
||||
// Create user account if there isn't one for the email address already
|
||||
user = await createUser(newUser)
|
||||
await events.createUser?.({ user })
|
||||
isNewUser = true
|
||||
}
|
||||
|
||||
// Create new session
|
||||
session = useJwtSession
|
||||
? {}
|
||||
: await createSession({
|
||||
sessionToken: generateSessionToken(),
|
||||
userId: user.id,
|
||||
expires: fromDate(options.session.maxAge),
|
||||
})
|
||||
|
||||
return { session, user, isNewUser }
|
||||
} else if (account.type === "oauth") {
|
||||
// If signing in with OAuth account, check to see if the account exists already
|
||||
const userByAccount = await getUserByAccount({
|
||||
providerAccountId: account.providerAccountId,
|
||||
provider: account.provider,
|
||||
})
|
||||
if (userByAccount) {
|
||||
if (user) {
|
||||
// If the user is already signed in with this account, we don't need to do anything
|
||||
if (userByAccount.id === user.id) {
|
||||
return { session, user, isNewUser }
|
||||
}
|
||||
// If the user is currently signed in, but the new account they are signing in
|
||||
// with is already associated with another user, then we cannot link them
|
||||
// and need to return an error.
|
||||
throw new AccountNotLinkedError(
|
||||
"The account is already associated with another user"
|
||||
)
|
||||
}
|
||||
// If there is no active session, but the account being signed in with is already
|
||||
// associated with a valid user then create session to sign the user in.
|
||||
session = useJwtSession
|
||||
? {}
|
||||
: await createSession({
|
||||
sessionToken: generateSessionToken(),
|
||||
userId: userByAccount.id,
|
||||
expires: fromDate(options.session.maxAge),
|
||||
})
|
||||
|
||||
return { session, user: userByAccount, isNewUser }
|
||||
} else {
|
||||
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.
|
||||
await linkAccount({ ...account, userId: user.id })
|
||||
await events.linkAccount?.({ user, account, profile })
|
||||
|
||||
// As they are already signed in, we don't need to do anything after linking them
|
||||
return { session, user, isNewUser }
|
||||
}
|
||||
|
||||
// If the user is not signed in and it looks like a new OAuth account then we
|
||||
// check there also isn't an user account already associated with the same
|
||||
// email address as the one in the OAuth profile.
|
||||
//
|
||||
// This step is often overlooked in OAuth implementations, but covers the following cases:
|
||||
//
|
||||
// 1. It makes it harder for someone to accidentally create two accounts.
|
||||
// e.g. by signin in with email, then again with an oauth account connected to the same email.
|
||||
// 2. It makes it harder to hijack a user account using a 3rd party OAuth account.
|
||||
// e.g. by creating an oauth account then changing the email address associated with it.
|
||||
//
|
||||
// It's quite common for services to automatically link accounts in this case, but it's
|
||||
// better practice to require the user to sign in *then* link accounts to be sure
|
||||
// someone is not exploiting a problem with a third party OAuth service.
|
||||
//
|
||||
// OAuth providers should require email address verification to prevent this, but in
|
||||
// practice that is not always the case; this helps protect against that.
|
||||
const userByEmail = profile.email
|
||||
? await getUserByEmail(profile.email)
|
||||
: null
|
||||
if (userByEmail) {
|
||||
const provider = options.provider as OAuthConfig<any>
|
||||
if (provider?.allowDangerousEmailAccountLinking) {
|
||||
// If you trust the oauth provider to correctly verify email addresses, you can opt-in to
|
||||
// account linking even when the user is not signed-in.
|
||||
user = userByEmail
|
||||
} else {
|
||||
// We end up here when we don't have an account with the same [provider].id *BUT*
|
||||
// we do already have an account with the same email address as the one in the
|
||||
// OAuth profile the user has just tried to sign in with.
|
||||
//
|
||||
// We don't want to have two accounts with the same email address, and we don't
|
||||
// want to link them in case it's not safe to do so, so instead we prompt the user
|
||||
// to sign in via email to verify their identity and then link the accounts.
|
||||
throw new AccountNotLinkedError(
|
||||
"Another account already exists with the same e-mail address"
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// If the current user is not logged in and the profile isn't linked to any user
|
||||
// accounts (by email or provider account id)...
|
||||
//
|
||||
// If no account matching the same [provider].id or .email exists, we can
|
||||
// create a new account for the user, link it to the OAuth acccount and
|
||||
// create a new session for them so they are signed in with it.
|
||||
const { id: _, ...newUser } = { ...profile, emailVerified: null }
|
||||
user = await createUser(newUser)
|
||||
}
|
||||
await events.createUser?.({ user })
|
||||
|
||||
await linkAccount({ ...account, userId: user.id })
|
||||
await events.linkAccount?.({ user, account, profile })
|
||||
|
||||
session = useJwtSession
|
||||
? {}
|
||||
: await createSession({
|
||||
sessionToken: generateSessionToken(),
|
||||
userId: user.id,
|
||||
expires: fromDate(options.session.maxAge),
|
||||
})
|
||||
|
||||
return { session, user, isNewUser: true }
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Unsupported account type")
|
||||
}
|
||||
42
packages/core/src/lib/callback-url.ts
Normal file
42
packages/core/src/lib/callback-url.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { InternalOptions } from ".."
|
||||
|
||||
interface CreateCallbackUrlParams {
|
||||
options: InternalOptions
|
||||
/** Try reading value from request body (POST) then from query param (GET) */
|
||||
paramValue?: string
|
||||
cookieValue?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Get callback URL based on query param / cookie + validation,
|
||||
* and add it to `req.options.callbackUrl`.
|
||||
*/
|
||||
export async function createCallbackUrl({
|
||||
options,
|
||||
paramValue,
|
||||
cookieValue,
|
||||
}: CreateCallbackUrlParams) {
|
||||
const { url, callbacks } = options
|
||||
|
||||
let callbackUrl = url.origin
|
||||
|
||||
if (paramValue) {
|
||||
// If callbackUrl form field or query parameter is passed try to use it if allowed
|
||||
callbackUrl = await callbacks.redirect({
|
||||
url: paramValue,
|
||||
baseUrl: url.origin,
|
||||
})
|
||||
} else if (cookieValue) {
|
||||
// If no callbackUrl specified, try using the value from the cookie if allowed
|
||||
callbackUrl = await callbacks.redirect({
|
||||
url: cookieValue,
|
||||
baseUrl: url.origin,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
callbackUrl,
|
||||
// Save callback URL in a cookie so that it can be used for subsequent requests in signin/signout/callback flow
|
||||
callbackUrlCookie: callbackUrl !== cookieValue ? callbackUrl : undefined,
|
||||
}
|
||||
}
|
||||
236
packages/core/src/lib/cookie.ts
Normal file
236
packages/core/src/lib/cookie.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
import type {
|
||||
CookieOption,
|
||||
CookiesOptions,
|
||||
LoggerInstance,
|
||||
SessionStrategy,
|
||||
} from ".."
|
||||
|
||||
// Uncomment to recalculate the estimated size
|
||||
// of an empty session cookie
|
||||
// import { serialize } from "cookie"
|
||||
// console.log(
|
||||
// "Cookie estimated to be ",
|
||||
// serialize(`__Secure.next-auth.session-token.0`, "", {
|
||||
// expires: new Date(),
|
||||
// httpOnly: true,
|
||||
// maxAge: Number.MAX_SAFE_INTEGER,
|
||||
// path: "/",
|
||||
// sameSite: "strict",
|
||||
// secure: true,
|
||||
// domain: "example.com",
|
||||
// }).length,
|
||||
// " bytes"
|
||||
// )
|
||||
|
||||
const ALLOWED_COOKIE_SIZE = 4096
|
||||
// Based on commented out section above
|
||||
const ESTIMATED_EMPTY_COOKIE_SIZE = 163
|
||||
const CHUNK_SIZE = ALLOWED_COOKIE_SIZE - ESTIMATED_EMPTY_COOKIE_SIZE
|
||||
|
||||
// REVIEW: Is there any way to defer two types of strings?
|
||||
|
||||
/** Stringified form of `JWT`. Extract the content with `jwt.decode` */
|
||||
export type JWTString = string
|
||||
|
||||
export type SetCookieOptions = Partial<CookieOption["options"]> & {
|
||||
expires?: Date | string
|
||||
encode?: (val: unknown) => string
|
||||
}
|
||||
|
||||
/**
|
||||
* If `options.session.strategy` is set to `jwt`, this is a stringified `JWT`.
|
||||
* In case of `strategy: "database"`, this is the `sessionToken` of the session in the database.
|
||||
*/
|
||||
export type SessionToken<T extends SessionStrategy = "jwt"> = T extends "jwt"
|
||||
? JWTString
|
||||
: string
|
||||
|
||||
/**
|
||||
* Use secure cookies if the site uses HTTPS
|
||||
* This being conditional allows cookies to work non-HTTPS development URLs
|
||||
* Honour secure cookie option, which sets 'secure' and also adds '__Secure-'
|
||||
* prefix, but enable them by default if the site URL is HTTPS; but not for
|
||||
* non-HTTPS URLs like http://localhost which are used in development).
|
||||
* For more on prefixes see https://googlechrome.github.io/samples/cookie-prefixes/
|
||||
*
|
||||
* @TODO Review cookie settings (names, options)
|
||||
*/
|
||||
export function defaultCookies(useSecureCookies: boolean): CookiesOptions {
|
||||
const cookiePrefix = useSecureCookies ? "__Secure-" : ""
|
||||
return {
|
||||
// default cookie options
|
||||
sessionToken: {
|
||||
name: `${cookiePrefix}next-auth.session-token`,
|
||||
options: {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
secure: useSecureCookies,
|
||||
},
|
||||
},
|
||||
callbackUrl: {
|
||||
name: `${cookiePrefix}next-auth.callback-url`,
|
||||
options: {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
secure: useSecureCookies,
|
||||
},
|
||||
},
|
||||
csrfToken: {
|
||||
// Default to __Host- for CSRF token for additional protection if using useSecureCookies
|
||||
// NB: The `__Host-` prefix is stricter than the `__Secure-` prefix.
|
||||
name: `${useSecureCookies ? "__Host-" : ""}next-auth.csrf-token`,
|
||||
options: {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
secure: useSecureCookies,
|
||||
},
|
||||
},
|
||||
pkceCodeVerifier: {
|
||||
name: `${cookiePrefix}next-auth.pkce.code_verifier`,
|
||||
options: {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
secure: useSecureCookies,
|
||||
maxAge: 60 * 15, // 15 minutes in seconds
|
||||
},
|
||||
},
|
||||
state: {
|
||||
name: `${cookiePrefix}next-auth.state`,
|
||||
options: {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
secure: useSecureCookies,
|
||||
maxAge: 60 * 15, // 15 minutes in seconds
|
||||
},
|
||||
},
|
||||
nonce: {
|
||||
name: `${cookiePrefix}next-auth.nonce`,
|
||||
options: {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
secure: useSecureCookies,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export interface Cookie extends CookieOption {
|
||||
value: string
|
||||
}
|
||||
|
||||
type Chunks = Record<string, string>
|
||||
|
||||
export class SessionStore {
|
||||
#chunks: Chunks = {}
|
||||
#option: CookieOption
|
||||
#logger: LoggerInstance | Console
|
||||
|
||||
constructor(
|
||||
option: CookieOption,
|
||||
req: Partial<{ cookies: any; headers: any }>,
|
||||
logger: LoggerInstance | Console
|
||||
) {
|
||||
this.#logger = logger
|
||||
this.#option = option
|
||||
|
||||
const { cookies } = req
|
||||
const { name: cookieName } = option
|
||||
|
||||
if (typeof cookies?.getAll === "function") {
|
||||
// Next.js ^v13.0.1 (Edge Env)
|
||||
for (const { name, value } of cookies.getAll()) {
|
||||
if (name.startsWith(cookieName)) {
|
||||
this.#chunks[name] = value
|
||||
}
|
||||
}
|
||||
} else if (cookies instanceof Map) {
|
||||
for (const name of cookies.keys()) {
|
||||
if (name.startsWith(cookieName)) this.#chunks[name] = cookies.get(name)
|
||||
}
|
||||
} else {
|
||||
for (const name in cookies) {
|
||||
if (name.startsWith(cookieName)) this.#chunks[name] = cookies[name]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get value() {
|
||||
return Object.values(this.#chunks)?.join("")
|
||||
}
|
||||
|
||||
/** Given a cookie, return a list of cookies, chunked to fit the allowed cookie size. */
|
||||
#chunk(cookie: Cookie): Cookie[] {
|
||||
const chunkCount = Math.ceil(cookie.value.length / CHUNK_SIZE)
|
||||
|
||||
if (chunkCount === 1) {
|
||||
this.#chunks[cookie.name] = cookie.value
|
||||
return [cookie]
|
||||
}
|
||||
|
||||
const cookies: Cookie[] = []
|
||||
for (let i = 0; i < chunkCount; i++) {
|
||||
const name = `${cookie.name}.${i}`
|
||||
const value = cookie.value.substr(i * CHUNK_SIZE, CHUNK_SIZE)
|
||||
cookies.push({ ...cookie, name, value })
|
||||
this.#chunks[name] = value
|
||||
}
|
||||
|
||||
this.#logger.debug("CHUNKING_SESSION_COOKIE", {
|
||||
message: `Session cookie exceeds allowed ${ALLOWED_COOKIE_SIZE} bytes.`,
|
||||
emptyCookieSize: ESTIMATED_EMPTY_COOKIE_SIZE,
|
||||
valueSize: cookie.value.length,
|
||||
chunks: cookies.map((c) => c.value.length + ESTIMATED_EMPTY_COOKIE_SIZE),
|
||||
})
|
||||
|
||||
return cookies
|
||||
}
|
||||
|
||||
/** Returns cleaned cookie chunks. */
|
||||
#clean(): Record<string, Cookie> {
|
||||
const cleanedChunks: Record<string, Cookie> = {}
|
||||
for (const name in this.#chunks) {
|
||||
delete this.#chunks?.[name]
|
||||
cleanedChunks[name] = {
|
||||
name,
|
||||
value: "",
|
||||
options: { ...this.#option.options, maxAge: 0 },
|
||||
}
|
||||
}
|
||||
return cleanedChunks
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a cookie value, return new cookies, chunked, to fit the allowed cookie size.
|
||||
* If the cookie has changed from chunked to unchunked or vice versa,
|
||||
* it deletes the old cookies as well.
|
||||
*/
|
||||
chunk(value: string, options: Partial<Cookie["options"]>): Cookie[] {
|
||||
// Assume all cookies should be cleaned by default
|
||||
const cookies: Record<string, Cookie> = this.#clean()
|
||||
|
||||
// Calculate new chunks
|
||||
const chunked = this.#chunk({
|
||||
name: this.#option.name,
|
||||
value,
|
||||
options: { ...this.#option.options, ...options },
|
||||
})
|
||||
|
||||
// Update stored chunks / cookies
|
||||
for (const chunk of chunked) {
|
||||
cookies[chunk.name] = chunk
|
||||
}
|
||||
|
||||
return Object.values(cookies)
|
||||
}
|
||||
|
||||
/** Returns a list of cookies that should be cleaned. */
|
||||
clean(): Cookie[] {
|
||||
return Object.values(this.#clean())
|
||||
}
|
||||
}
|
||||
54
packages/core/src/lib/csrf-token.ts
Normal file
54
packages/core/src/lib/csrf-token.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { createHash, randomString } from "./web"
|
||||
import type { InternalOptions } from "./types"
|
||||
|
||||
interface CreateCSRFTokenParams {
|
||||
options: InternalOptions
|
||||
cookieValue?: string
|
||||
isPost: boolean
|
||||
bodyValue?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure CSRF Token cookie is set for any subsequent requests.
|
||||
* Used as part of the strategy for mitigation for CSRF tokens.
|
||||
*
|
||||
* Creates a cookie like 'next-auth.csrf-token' with the value 'token|hash',
|
||||
* where 'token' is the CSRF token and 'hash' is a hash made of the token and
|
||||
* the secret, and the two values are joined by a pipe '|'. By storing the
|
||||
* value and the hash of the value (with the secret used as a salt) we can
|
||||
* verify the cookie was set by the server and not by a malicous attacker.
|
||||
*
|
||||
* For more details, see the following OWASP links:
|
||||
* https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#double-submit-cookie
|
||||
* https://owasp.org/www-chapter-london/assets/slides/David_Johansson-Double_Defeat_of_Double-Submit_Cookie.pdf
|
||||
*/
|
||||
export async function createCSRFToken({
|
||||
options,
|
||||
cookieValue,
|
||||
isPost,
|
||||
bodyValue,
|
||||
}: CreateCSRFTokenParams) {
|
||||
if (cookieValue) {
|
||||
const [csrfToken, csrfTokenHash] = cookieValue.split("|")
|
||||
|
||||
const expectedCsrfTokenHash = await createHash(
|
||||
`${csrfToken}${options.secret}`
|
||||
)
|
||||
|
||||
if (csrfTokenHash === expectedCsrfTokenHash) {
|
||||
// If hash matches then we trust the CSRF token value
|
||||
// If this is a POST request and the CSRF Token in the POST request matches
|
||||
// the cookie we have already verified is the one we have set, then the token is verified!
|
||||
const csrfTokenVerified = isPost && csrfToken === bodyValue
|
||||
|
||||
return { csrfTokenVerified, csrfToken }
|
||||
}
|
||||
}
|
||||
|
||||
// New CSRF token
|
||||
const csrfToken = randomString(32)
|
||||
const csrfTokenHash = await createHash(`${csrfToken}${options.secret}`)
|
||||
const cookie = `${csrfToken}|${csrfTokenHash}`
|
||||
|
||||
return { cookie, csrfToken }
|
||||
}
|
||||
18
packages/core/src/lib/default-callbacks.ts
Normal file
18
packages/core/src/lib/default-callbacks.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { CallbacksOptions } from ".."
|
||||
|
||||
export const defaultCallbacks: CallbacksOptions = {
|
||||
signIn() {
|
||||
return true
|
||||
},
|
||||
redirect({ url, baseUrl }) {
|
||||
if (url.startsWith("/")) return `${baseUrl}${url}`
|
||||
else if (new URL(url).origin === baseUrl) return url
|
||||
return baseUrl
|
||||
},
|
||||
session({ session }) {
|
||||
return session
|
||||
},
|
||||
jwt({ token }) {
|
||||
return token
|
||||
},
|
||||
}
|
||||
20
packages/core/src/lib/email/getUserFromEmail.ts
Normal file
20
packages/core/src/lib/email/getUserFromEmail.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { AdapterUser } from "../../adapters"
|
||||
import type { InternalOptions } from "../.."
|
||||
|
||||
/**
|
||||
* Query the database for a user by email address.
|
||||
* If is an existing user return a user object (otherwise use placeholder).
|
||||
*/
|
||||
export default async function getAdapterUserFromEmail({
|
||||
email,
|
||||
adapter,
|
||||
}: {
|
||||
email: string
|
||||
adapter: InternalOptions<"email">["adapter"]
|
||||
}): Promise<AdapterUser> {
|
||||
const { getUserByEmail } = adapter
|
||||
const adapterUser = email ? await getUserByEmail(email) : null
|
||||
if (adapterUser) return adapterUser
|
||||
|
||||
return { id: email, email, emailVerified: null }
|
||||
}
|
||||
49
packages/core/src/lib/email/signin.ts
Normal file
49
packages/core/src/lib/email/signin.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { randomString, createHash } from "../web"
|
||||
import type { InternalOptions } from "../.."
|
||||
|
||||
/**
|
||||
* Starts an e-mail login flow, by generating a token,
|
||||
* and sending it to the user's e-mail (with the help of a DB adapter)
|
||||
*/
|
||||
export default async function email(
|
||||
identifier: string,
|
||||
options: InternalOptions<"email">
|
||||
): Promise<string> {
|
||||
const { url, adapter, provider, callbackUrl, theme } = options
|
||||
// Generate token
|
||||
const token =
|
||||
(await provider.generateVerificationToken?.()) ?? randomString(32)
|
||||
|
||||
const ONE_DAY_IN_SECONDS = 86400
|
||||
const expires = new Date(
|
||||
Date.now() + (provider.maxAge ?? ONE_DAY_IN_SECONDS) * 1000
|
||||
)
|
||||
|
||||
// Generate a link with email, unhashed token and callback url
|
||||
const params = new URLSearchParams({ callbackUrl, token, email: identifier })
|
||||
const _url = `${url}/callback/${provider.id}?${params}`
|
||||
|
||||
const secret = provider.secret ?? options.secret
|
||||
await Promise.all([
|
||||
// Send to user
|
||||
provider.sendVerificationRequest({
|
||||
identifier,
|
||||
token,
|
||||
expires,
|
||||
url: _url,
|
||||
provider,
|
||||
theme,
|
||||
}),
|
||||
// Save in database
|
||||
adapter.createVerificationToken({
|
||||
identifier,
|
||||
token: await createHash(`${token}${secret}`),
|
||||
expires,
|
||||
}),
|
||||
])
|
||||
|
||||
return `${url}/verify-request?${new URLSearchParams({
|
||||
provider: provider.id,
|
||||
type: provider.type,
|
||||
})}`
|
||||
}
|
||||
141
packages/core/src/lib/errors.ts
Normal file
141
packages/core/src/lib/errors.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import type { EventCallbacks, LoggerInstance } from "./types"
|
||||
|
||||
/**
|
||||
* Same as the default `Error`, but it is JSON serializable.
|
||||
* @source https://iaincollins.medium.com/error-handling-in-javascript-a6172ccdf9af
|
||||
*/
|
||||
export class UnknownError extends Error {
|
||||
code: string
|
||||
constructor(error: Error | string) {
|
||||
// Support passing error or string
|
||||
super((error as Error)?.message ?? error)
|
||||
this.name = "UnknownError"
|
||||
this.code = (error as any).code
|
||||
if (error instanceof Error) {
|
||||
this.stack = error.stack
|
||||
}
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
name: this.name,
|
||||
message: this.message,
|
||||
stack: this.stack,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class OAuthCallbackError extends UnknownError {
|
||||
name = "OAuthCallbackError"
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown when an Email address is already associated with an account
|
||||
* but the user is trying an OAuth account that is not linked to it.
|
||||
*/
|
||||
export class AccountNotLinkedError extends UnknownError {
|
||||
name = "AccountNotLinkedError"
|
||||
}
|
||||
|
||||
export class MissingAPIRoute extends UnknownError {
|
||||
name = "MissingAPIRouteError"
|
||||
code = "MISSING_NEXTAUTH_API_ROUTE_ERROR"
|
||||
}
|
||||
|
||||
export class MissingSecret extends UnknownError {
|
||||
name = "MissingSecretError"
|
||||
code = "NO_SECRET"
|
||||
}
|
||||
|
||||
export class MissingAuthorize extends UnknownError {
|
||||
name = "MissingAuthorizeError"
|
||||
code = "CALLBACK_CREDENTIALS_HANDLER_ERROR"
|
||||
}
|
||||
|
||||
export class MissingAdapter extends UnknownError {
|
||||
name = "MissingAdapterError"
|
||||
code = "EMAIL_REQUIRES_ADAPTER_ERROR"
|
||||
}
|
||||
|
||||
export class MissingAdapterMethods extends UnknownError {
|
||||
name = "MissingAdapterMethodsError"
|
||||
code = "MISSING_ADAPTER_METHODS_ERROR"
|
||||
}
|
||||
|
||||
export class UnsupportedStrategy extends UnknownError {
|
||||
name = "UnsupportedStrategyError"
|
||||
code = "CALLBACK_CREDENTIALS_JWT_ERROR"
|
||||
}
|
||||
|
||||
export class InvalidCallbackUrl extends UnknownError {
|
||||
name = "InvalidCallbackUrlError"
|
||||
code = "INVALID_CALLBACK_URL_ERROR"
|
||||
}
|
||||
|
||||
export class InvalidEndpoints extends UnknownError {
|
||||
name = "InvalidEndpoints"
|
||||
code = "INVALID_ENDPOINTS_ERROR"
|
||||
}
|
||||
export class UnknownAction extends UnknownError {
|
||||
name = "UnknownAction"
|
||||
code = "UNKNOWN_ACTION_ERROR"
|
||||
}
|
||||
|
||||
export class UntrustedHost extends UnknownError {
|
||||
name = "UntrustedHost"
|
||||
code = "UNTRUST_HOST_ERROR"
|
||||
}
|
||||
|
||||
type Method = (...args: any[]) => Promise<any>
|
||||
|
||||
export function upperSnake(s: string) {
|
||||
return s.replace(/([A-Z])/g, "_$1").toUpperCase()
|
||||
}
|
||||
|
||||
export function capitalize(s: string) {
|
||||
return `${s[0].toUpperCase()}${s.slice(1)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps an object of methods and adds error handling.
|
||||
*/
|
||||
export function eventsErrorHandler(
|
||||
methods: Partial<EventCallbacks>,
|
||||
logger: LoggerInstance
|
||||
): Partial<EventCallbacks> {
|
||||
return Object.keys(methods).reduce<any>((acc, name) => {
|
||||
acc[name] = async (...args: any[]) => {
|
||||
try {
|
||||
const method: Method = methods[name as keyof Method]
|
||||
return await method(...args)
|
||||
} catch (e) {
|
||||
logger.error(`${upperSnake(name)}_EVENT_ERROR`, e as Error)
|
||||
}
|
||||
}
|
||||
return acc
|
||||
}, {})
|
||||
}
|
||||
|
||||
/** Handles adapter induced errors. */
|
||||
export function adapterErrorHandler<TAdapter>(
|
||||
adapter: TAdapter | undefined,
|
||||
logger: LoggerInstance
|
||||
): TAdapter | undefined {
|
||||
if (!adapter) return
|
||||
|
||||
return Object.keys(adapter).reduce<any>((acc, name) => {
|
||||
acc[name] = async (...args: any[]) => {
|
||||
try {
|
||||
logger.debug(`adapter_${name}`, { args })
|
||||
const method: Method = adapter[name as keyof Method]
|
||||
return await method(...args)
|
||||
} catch (error) {
|
||||
logger.error(`adapter_error_${name}`, error as Error)
|
||||
const e = new UnknownError(error as Error)
|
||||
e.name = `${capitalize(name)}Error`
|
||||
throw e
|
||||
}
|
||||
}
|
||||
return acc
|
||||
}, {})
|
||||
}
|
||||
152
packages/core/src/lib/init.ts
Normal file
152
packages/core/src/lib/init.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { adapterErrorHandler, eventsErrorHandler } from "./errors"
|
||||
import * as jwt from "../jwt"
|
||||
import { createCallbackUrl } from "./callback-url"
|
||||
import * as cookie from "./cookie"
|
||||
import { createCSRFToken } from "./csrf-token"
|
||||
import { defaultCallbacks } from "./default-callbacks"
|
||||
import parseProviders from "./providers"
|
||||
import logger from "./utils/logger"
|
||||
import parseUrl from "./utils/parse-url"
|
||||
|
||||
import type { AuthOptions, InternalOptions, RequestInternal } from ".."
|
||||
|
||||
interface InitParams {
|
||||
url: URL
|
||||
authOptions: AuthOptions
|
||||
providerId?: string
|
||||
action: InternalOptions["action"]
|
||||
/** Callback URL value extracted from the incoming request. */
|
||||
callbackUrl?: string
|
||||
/** CSRF token value extracted from the incoming request. From body if POST, from query if GET */
|
||||
csrfToken?: string
|
||||
/** Is the incoming request a POST request? */
|
||||
isPost: boolean
|
||||
cookies: RequestInternal["cookies"]
|
||||
}
|
||||
|
||||
/** Initialize all internal options and cookies. */
|
||||
export async function init({
|
||||
authOptions,
|
||||
providerId,
|
||||
action,
|
||||
url: reqUrl,
|
||||
cookies: reqCookies,
|
||||
callbackUrl: reqCallbackUrl,
|
||||
csrfToken: reqCsrfToken,
|
||||
isPost,
|
||||
}: InitParams): Promise<{
|
||||
options: InternalOptions
|
||||
cookies: cookie.Cookie[]
|
||||
}> {
|
||||
// TODO: move this to web.ts
|
||||
const parsed = parseUrl(
|
||||
reqUrl.origin +
|
||||
reqUrl.pathname.replace(`/${action}`, "").replace(`/${providerId}`, "")
|
||||
)
|
||||
const url = new URL(parsed.toString())
|
||||
|
||||
const { providers, provider } = parseProviders({
|
||||
providers: authOptions.providers,
|
||||
url,
|
||||
providerId,
|
||||
})
|
||||
|
||||
const maxAge = 30 * 24 * 60 * 60 // Sessions expire after 30 days of being idle by default
|
||||
|
||||
// User provided options are overriden by other options,
|
||||
// except for the options with special handling above
|
||||
const options: InternalOptions = {
|
||||
debug: false,
|
||||
pages: {},
|
||||
theme: {
|
||||
colorScheme: "auto",
|
||||
logo: "",
|
||||
brandColor: "",
|
||||
buttonText: "",
|
||||
},
|
||||
// Custom options override defaults
|
||||
...authOptions,
|
||||
// These computed settings can have values in userOptions but we override them
|
||||
// and are request-specific.
|
||||
url,
|
||||
action,
|
||||
// @ts-expect-errors
|
||||
provider,
|
||||
cookies: {
|
||||
...cookie.defaultCookies(
|
||||
authOptions.useSecureCookies ?? url.protocol === "https:"
|
||||
),
|
||||
// Allow user cookie options to override any cookie settings above
|
||||
...authOptions.cookies,
|
||||
},
|
||||
providers,
|
||||
// Session options
|
||||
session: {
|
||||
// If no adapter specified, force use of JSON Web Tokens (stateless)
|
||||
strategy: authOptions.adapter ? "database" : "jwt",
|
||||
maxAge,
|
||||
updateAge: 24 * 60 * 60,
|
||||
generateSessionToken: crypto.randomUUID,
|
||||
...authOptions.session,
|
||||
},
|
||||
// JWT options
|
||||
jwt: {
|
||||
// Asserted in assert.ts
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
secret: authOptions.secret!,
|
||||
maxAge, // same as session maxAge,
|
||||
encode: jwt.encode,
|
||||
decode: jwt.decode,
|
||||
...authOptions.jwt,
|
||||
},
|
||||
// Event messages
|
||||
events: eventsErrorHandler(authOptions.events ?? {}, logger),
|
||||
adapter: adapterErrorHandler(authOptions.adapter, logger),
|
||||
// Callback functions
|
||||
callbacks: { ...defaultCallbacks, ...authOptions.callbacks },
|
||||
logger,
|
||||
callbackUrl: url.origin,
|
||||
}
|
||||
|
||||
// Init cookies
|
||||
|
||||
const cookies: cookie.Cookie[] = []
|
||||
|
||||
const {
|
||||
csrfToken,
|
||||
cookie: csrfCookie,
|
||||
csrfTokenVerified,
|
||||
} = await createCSRFToken({
|
||||
options,
|
||||
cookieValue: reqCookies?.[options.cookies.csrfToken.name],
|
||||
isPost,
|
||||
bodyValue: reqCsrfToken,
|
||||
})
|
||||
|
||||
options.csrfToken = csrfToken
|
||||
options.csrfTokenVerified = csrfTokenVerified
|
||||
|
||||
if (csrfCookie) {
|
||||
cookies.push({
|
||||
name: options.cookies.csrfToken.name,
|
||||
value: csrfCookie,
|
||||
options: options.cookies.csrfToken.options,
|
||||
})
|
||||
}
|
||||
|
||||
const { callbackUrl, callbackUrlCookie } = await createCallbackUrl({
|
||||
options,
|
||||
cookieValue: reqCookies?.[options.cookies.callbackUrl.name],
|
||||
paramValue: reqCallbackUrl,
|
||||
})
|
||||
options.callbackUrl = callbackUrl
|
||||
if (callbackUrlCookie) {
|
||||
cookies.push({
|
||||
name: options.cookies.callbackUrl.name,
|
||||
value: callbackUrlCookie,
|
||||
options: options.cookies.callbackUrl.options,
|
||||
})
|
||||
}
|
||||
|
||||
return { options, cookies }
|
||||
}
|
||||
147
packages/core/src/lib/oauth/authorization-url.ts
Normal file
147
packages/core/src/lib/oauth/authorization-url.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import * as o from "oauth4webapi"
|
||||
|
||||
import type {
|
||||
CookiesOptions,
|
||||
InternalOptions,
|
||||
RequestInternal,
|
||||
ResponseInternal,
|
||||
} from "../.."
|
||||
import type { Cookie } from "../cookie"
|
||||
|
||||
/**
|
||||
* Generates an authorization/request token URL.
|
||||
*
|
||||
* [OAuth 2](https://www.oauth.com/oauth2-servers/authorization/the-authorization-request/)
|
||||
*/
|
||||
export async function getAuthorizationUrl({
|
||||
options,
|
||||
query,
|
||||
}: {
|
||||
options: InternalOptions<"oauth">
|
||||
query: RequestInternal["query"]
|
||||
}): Promise<ResponseInternal> {
|
||||
const { logger, provider } = options
|
||||
|
||||
let url = provider.authorization?.url
|
||||
let as: o.AuthorizationServer | undefined
|
||||
|
||||
if (!url) {
|
||||
// If url is undefined, we assume that issuer is always defined
|
||||
// We check this in assert.ts
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const issuer = new URL(provider.issuer!)
|
||||
const discoveryResponse = await o.discoveryRequest(issuer)
|
||||
const as = await o.processDiscoveryResponse(issuer, discoveryResponse)
|
||||
|
||||
if (!as.authorization_endpoint) {
|
||||
throw new TypeError(
|
||||
"Authorization server did not provide an authorization endpoint."
|
||||
)
|
||||
}
|
||||
|
||||
url = new URL(as.authorization_endpoint)
|
||||
}
|
||||
|
||||
const authParams = url.searchParams
|
||||
const params = Object.assign(
|
||||
{
|
||||
response_type: "code",
|
||||
// clientId can technically be undefined, should we check this in assert.ts or rely on the Authorization Server to do it?
|
||||
client_id: provider.clientId,
|
||||
redirect_uri: provider.callbackUrl,
|
||||
// @ts-expect-error TODO:
|
||||
...provider.authorization?.params,
|
||||
}, // Defaults
|
||||
Object.fromEntries(authParams), // From provider config
|
||||
query // From `signIn` call
|
||||
)
|
||||
|
||||
for (const k in params) authParams.set(k, params[k])
|
||||
|
||||
const cookies: Cookie[] = []
|
||||
|
||||
if (provider.checks?.includes("state")) {
|
||||
const { value, raw } = await createState(options)
|
||||
authParams.set("state", raw)
|
||||
cookies.push(value)
|
||||
}
|
||||
|
||||
if (provider.checks?.includes("pkce")) {
|
||||
if (as && !as.code_challenge_methods_supported?.includes("S256")) {
|
||||
// We assume S256 PKCE support, if the server does not advertise that,
|
||||
// a random `nonce` must be used for CSRF protection.
|
||||
provider.checks = ["nonce"]
|
||||
} else {
|
||||
const { code_challenge, pkce } = await createPKCE(options)
|
||||
authParams.set("code_challenge", code_challenge)
|
||||
authParams.set("code_challenge_method", "S256")
|
||||
cookies.push(pkce)
|
||||
}
|
||||
}
|
||||
|
||||
if (provider.checks?.includes("nonce")) {
|
||||
const nonce = await createNonce(options)
|
||||
authParams.set("nonce", nonce.value)
|
||||
cookies.push(nonce)
|
||||
}
|
||||
|
||||
url.searchParams.delete("nextauth")
|
||||
|
||||
// TODO: This does not work in normalizeOAuth because authorization endpoint can come from discovery
|
||||
// Need to make normalizeOAuth async
|
||||
if (provider.type === "oidc" && !url.searchParams.has("scope")) {
|
||||
url.searchParams.set("scope", "openid profile email")
|
||||
}
|
||||
|
||||
logger.debug("GET_AUTHORIZATION_URL", { url, cookies, provider })
|
||||
return { redirect: url, cookies }
|
||||
}
|
||||
|
||||
/** Returns a signed cookie. */
|
||||
export async function signCookie(
|
||||
type: keyof CookiesOptions,
|
||||
value: string,
|
||||
maxAge: number,
|
||||
options: InternalOptions<"oauth">
|
||||
): Promise<Cookie> {
|
||||
const { cookies, jwt, logger } = options
|
||||
|
||||
logger.debug(`CREATE_${type.toUpperCase()}`, { value, maxAge })
|
||||
|
||||
const expires = new Date()
|
||||
expires.setTime(expires.getTime() + maxAge * 1000)
|
||||
return {
|
||||
name: cookies[type].name,
|
||||
value: await jwt.encode({ ...jwt, maxAge, token: { value } }),
|
||||
options: { ...cookies[type].options, expires },
|
||||
}
|
||||
}
|
||||
|
||||
const STATE_MAX_AGE = 60 * 15 // 15 minutes in seconds
|
||||
async function createState(options: InternalOptions<"oauth">) {
|
||||
const raw = o.generateRandomState()
|
||||
const maxAge = STATE_MAX_AGE
|
||||
const value = await signCookie("state", raw, maxAge, options)
|
||||
return { value, raw }
|
||||
}
|
||||
|
||||
const PKCE_MAX_AGE = 60 * 15 // 15 minutes in seconds
|
||||
async function createPKCE(options: InternalOptions<"oauth">) {
|
||||
const code_verifier = o.generateRandomCodeVerifier()
|
||||
const code_challenge = await o.calculatePKCECodeChallenge(code_verifier)
|
||||
const maxAge = PKCE_MAX_AGE
|
||||
const pkce = await signCookie(
|
||||
"pkceCodeVerifier",
|
||||
code_verifier,
|
||||
maxAge,
|
||||
options
|
||||
)
|
||||
return { code_challenge, pkce }
|
||||
}
|
||||
|
||||
const NONCE_MAX_AGE = 60 * 15 // 15 minutes in seconds
|
||||
async function createNonce(options: InternalOptions<"oauth">) {
|
||||
const raw = o.generateRandomNonce()
|
||||
const maxAge = NONCE_MAX_AGE
|
||||
return await signCookie("nonce", raw, maxAge, options)
|
||||
}
|
||||
221
packages/core/src/lib/oauth/callback.ts
Normal file
221
packages/core/src/lib/oauth/callback.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import { OAuthCallbackError } from "../errors"
|
||||
import { useNonce } from "./nonce-handler"
|
||||
import { usePKCECodeVerifier } from "./pkce-handler"
|
||||
import { useState } from "./state-handler"
|
||||
import * as o from "oauth4webapi"
|
||||
|
||||
import type {
|
||||
InternalOptions,
|
||||
LoggerInstance,
|
||||
Profile,
|
||||
RequestInternal,
|
||||
TokenSet,
|
||||
} from "../.."
|
||||
import type { OAuthConfigInternal } from "../../providers"
|
||||
import type { Cookie } from "../cookie"
|
||||
|
||||
export async function handleOAuthCallback(params: {
|
||||
options: InternalOptions<"oauth">
|
||||
query: RequestInternal["query"]
|
||||
body: RequestInternal["body"]
|
||||
cookies: RequestInternal["cookies"]
|
||||
}) {
|
||||
const { options, query, body, cookies } = params
|
||||
const { logger, provider } = options
|
||||
|
||||
const errorMessage = body?.error ?? query?.error
|
||||
if (errorMessage) {
|
||||
const error = new Error(errorMessage)
|
||||
logger.error("OAUTH_CALLBACK_HANDLER_ERROR", {
|
||||
error,
|
||||
error_description: query?.error_description,
|
||||
providerId: provider.id,
|
||||
})
|
||||
logger.debug("OAUTH_CALLBACK_HANDLER_ERROR", { body })
|
||||
throw error
|
||||
}
|
||||
|
||||
try {
|
||||
let as: o.AuthorizationServer
|
||||
|
||||
if (!provider.token?.url && !provider.userinfo?.url) {
|
||||
// We assume that issuer is always defined as this has been asserted earlier
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const issuer = new URL(provider.issuer!)
|
||||
const discoveryResponse = await o.discoveryRequest(issuer)
|
||||
const discoveredAs = await o.processDiscoveryResponse(
|
||||
issuer,
|
||||
discoveryResponse
|
||||
)
|
||||
|
||||
if (!discoveredAs.token_endpoint)
|
||||
throw new TypeError(
|
||||
"TODO: Authorization server did not provide a token endpoint."
|
||||
)
|
||||
|
||||
if (!discoveredAs.userinfo_endpoint)
|
||||
throw new TypeError(
|
||||
"TODO: Authorization server did not provide a userinfo endpoint."
|
||||
)
|
||||
|
||||
as = discoveredAs
|
||||
} else {
|
||||
as = {
|
||||
issuer: provider.issuer ?? "https://a", // TODO: review fallback issuer
|
||||
token_endpoint: provider.token?.url.toString(),
|
||||
userinfo_endpoint: provider.userinfo?.url.toString(),
|
||||
}
|
||||
}
|
||||
|
||||
const client: o.Client = {
|
||||
client_id: provider.clientId,
|
||||
client_secret: provider.clientSecret,
|
||||
...provider.client,
|
||||
}
|
||||
|
||||
const resCookies: Cookie[] = []
|
||||
|
||||
const state = await useState(cookies?.[options.cookies.state.name], options)
|
||||
if (state) resCookies.push(state.cookie)
|
||||
|
||||
const codeVerifier = await usePKCECodeVerifier(
|
||||
cookies?.[options.cookies.pkceCodeVerifier.name],
|
||||
options
|
||||
)
|
||||
if (codeVerifier) resCookies.push(codeVerifier.cookie)
|
||||
|
||||
// TODO:
|
||||
const nonce = await useNonce(cookies?.[options.cookies.nonce.name], options)
|
||||
if (nonce && provider.type === "oidc") {
|
||||
resCookies.push(nonce.cookie)
|
||||
}
|
||||
|
||||
const parameters = o.validateAuthResponse(
|
||||
as,
|
||||
client,
|
||||
new URLSearchParams(query),
|
||||
provider.checks.includes("state") ? state?.value : o.skipStateCheck
|
||||
)
|
||||
|
||||
if (o.isOAuth2Error(parameters)) {
|
||||
console.log("error", parameters)
|
||||
throw new Error("TODO: Handle OAuth 2.0 redirect error")
|
||||
}
|
||||
|
||||
const codeGrantResponse = await o.authorizationCodeGrantRequest(
|
||||
as,
|
||||
client,
|
||||
parameters,
|
||||
provider.callbackUrl,
|
||||
codeVerifier?.codeVerifier ?? "auth" // TODO: review fallback code verifier
|
||||
)
|
||||
|
||||
let challenges: o.WWWAuthenticateChallenge[] | undefined
|
||||
if ((challenges = o.parseWwwAuthenticateChallenges(codeGrantResponse))) {
|
||||
for (const challenge of challenges) {
|
||||
console.log("challenge", challenge)
|
||||
}
|
||||
throw new Error("TODO: Handle www-authenticate challenges as needed")
|
||||
}
|
||||
|
||||
let profile: Profile = {}
|
||||
let tokens: TokenSet
|
||||
|
||||
if (provider.type === "oidc") {
|
||||
const result = await o.processAuthorizationCodeOpenIDResponse(
|
||||
as,
|
||||
client,
|
||||
codeGrantResponse
|
||||
)
|
||||
|
||||
if (o.isOAuth2Error(result)) {
|
||||
console.log("error", result)
|
||||
throw new Error("TODO: Handle OIDC response body error")
|
||||
}
|
||||
|
||||
profile = o.getValidatedIdTokenClaims(result)
|
||||
tokens = result
|
||||
} else {
|
||||
tokens = await o.processAuthorizationCodeOAuth2Response(
|
||||
as,
|
||||
client,
|
||||
codeGrantResponse
|
||||
)
|
||||
if (o.isOAuth2Error(tokens as any)) {
|
||||
console.log("error", tokens)
|
||||
throw new Error("TODO: Handle OAuth 2.0 response body error")
|
||||
}
|
||||
|
||||
if (provider.userinfo?.request) {
|
||||
profile = await provider.userinfo.request({ tokens, provider })
|
||||
} else if (provider.userinfo?.url) {
|
||||
const userinfoResponse = await o.userInfoRequest(
|
||||
as,
|
||||
client,
|
||||
(tokens as any).access_token
|
||||
)
|
||||
profile = await userinfoResponse.json()
|
||||
}
|
||||
}
|
||||
|
||||
const profileResult = await getProfile({
|
||||
profile,
|
||||
provider,
|
||||
tokens,
|
||||
logger,
|
||||
})
|
||||
|
||||
return { ...profileResult, cookies: resCookies }
|
||||
} catch (error) {
|
||||
throw new OAuthCallbackError(error as Error)
|
||||
}
|
||||
}
|
||||
|
||||
interface GetProfileParams {
|
||||
profile: Profile
|
||||
tokens: TokenSet
|
||||
provider: OAuthConfigInternal<any>
|
||||
logger: LoggerInstance
|
||||
}
|
||||
|
||||
/** Returns profile, raw profile and auth provider details */
|
||||
async function getProfile({
|
||||
profile: OAuthProfile,
|
||||
tokens,
|
||||
provider,
|
||||
logger,
|
||||
}: GetProfileParams) {
|
||||
try {
|
||||
logger.debug("PROFILE_DATA", { OAuthProfile })
|
||||
const profile = await provider.profile(OAuthProfile, tokens)
|
||||
profile.email = profile.email?.toLowerCase()
|
||||
if (!profile.id)
|
||||
throw new TypeError(
|
||||
`Profile id is missing in ${provider.name} OAuth profile response`
|
||||
)
|
||||
|
||||
// Return profile, raw profile and auth provider details
|
||||
return {
|
||||
profile,
|
||||
account: {
|
||||
provider: provider.id,
|
||||
type: provider.type,
|
||||
providerAccountId: profile.id.toString(),
|
||||
...tokens,
|
||||
},
|
||||
OAuthProfile,
|
||||
}
|
||||
} catch (error) {
|
||||
// If we didn't get a response either there was a problem with the provider
|
||||
// response *or* the user cancelled the action with the provider.
|
||||
//
|
||||
// Unfortuately, we can't tell which - at least not in a way that works for
|
||||
// all providers, so we return an empty object; the user should then be
|
||||
// redirected back to the sign up page. We log the error to help developers
|
||||
// who might be trying to debug this when configuring a new provider.
|
||||
logger.error("OAUTH_PARSE_PROFILE_ERROR", {
|
||||
error: error as Error,
|
||||
OAuthProfile,
|
||||
})
|
||||
}
|
||||
}
|
||||
76
packages/core/src/lib/oauth/nonce-handler.ts
Normal file
76
packages/core/src/lib/oauth/nonce-handler.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import * as o from "oauth4webapi"
|
||||
import * as jwt from "../../jwt"
|
||||
|
||||
import type { InternalOptions } from "../.."
|
||||
import type { Cookie } from "../cookie"
|
||||
|
||||
const NONCE_MAX_AGE = 60 * 15 // 15 minutes in seconds
|
||||
|
||||
/**
|
||||
* Returns nonce if the provider supports it
|
||||
* and saves it in a cookie */
|
||||
export async function createNonce(options: InternalOptions<"oauth">): Promise<
|
||||
| undefined
|
||||
| {
|
||||
value: string
|
||||
cookie: Cookie
|
||||
}
|
||||
> {
|
||||
const { cookies, logger, provider } = options
|
||||
if (!provider.checks?.includes("nonce")) {
|
||||
// Provider does not support nonce, return nothing.
|
||||
return
|
||||
}
|
||||
|
||||
const nonce = o.generateRandomNonce()
|
||||
|
||||
const expires = new Date()
|
||||
expires.setTime(expires.getTime() + NONCE_MAX_AGE * 1000)
|
||||
|
||||
// Encrypt nonce and save it to an encrypted cookie
|
||||
const encryptedNonce = await jwt.encode({
|
||||
...options.jwt,
|
||||
maxAge: NONCE_MAX_AGE,
|
||||
token: { nonce },
|
||||
})
|
||||
|
||||
logger.debug("CREATE_ENCRYPTED_NONCE", {
|
||||
nonce,
|
||||
maxAge: NONCE_MAX_AGE,
|
||||
})
|
||||
|
||||
return {
|
||||
cookie: {
|
||||
name: cookies.nonce.name,
|
||||
value: encryptedNonce,
|
||||
options: { ...cookies.nonce.options, expires },
|
||||
},
|
||||
value: nonce,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns nonce from if the provider supports nonce,
|
||||
* and clears the container cookie afterwards.
|
||||
*/
|
||||
export async function useNonce(
|
||||
nonce: string | undefined,
|
||||
options: InternalOptions<"oauth">
|
||||
): Promise<{ value: string; cookie: Cookie } | undefined> {
|
||||
const { cookies, provider } = options
|
||||
|
||||
if (!provider?.checks?.includes("nonce") || !nonce) {
|
||||
return
|
||||
}
|
||||
|
||||
const value = (await jwt.decode({ ...options.jwt, token: nonce })) as any
|
||||
|
||||
return {
|
||||
value: value?.value ?? undefined,
|
||||
cookie: {
|
||||
name: cookies.nonce.name,
|
||||
value: "",
|
||||
options: { ...cookies.nonce.options, maxAge: 0 },
|
||||
},
|
||||
}
|
||||
}
|
||||
87
packages/core/src/lib/oauth/pkce-handler.ts
Normal file
87
packages/core/src/lib/oauth/pkce-handler.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import * as o from "oauth4webapi"
|
||||
import * as jwt from "../../jwt"
|
||||
|
||||
import type { InternalOptions } from "../.."
|
||||
import type { Cookie } from "../cookie"
|
||||
|
||||
const PKCE_CODE_CHALLENGE_METHOD = "S256"
|
||||
const PKCE_MAX_AGE = 60 * 15 // 15 minutes in seconds
|
||||
|
||||
/**
|
||||
* Returns `code_challenge` and `code_challenge_method`
|
||||
* and saves them in a cookie.
|
||||
*/
|
||||
export async function createPKCE(options: InternalOptions<"oauth">): Promise<
|
||||
| undefined
|
||||
| {
|
||||
code_challenge: string
|
||||
code_challenge_method: "S256"
|
||||
cookie: Cookie
|
||||
}
|
||||
> {
|
||||
const { cookies, logger, provider } = options
|
||||
if (!provider.checks?.includes("pkce")) {
|
||||
// Provider does not support PKCE, return nothing.
|
||||
return
|
||||
}
|
||||
const code_verifier = o.generateRandomCodeVerifier()
|
||||
const code_challenge = await o.calculatePKCECodeChallenge(code_verifier)
|
||||
|
||||
const maxAge = cookies.pkceCodeVerifier.options.maxAge ?? PKCE_MAX_AGE
|
||||
|
||||
const expires = new Date()
|
||||
expires.setTime(expires.getTime() + maxAge * 1000)
|
||||
|
||||
// Encrypt code_verifier and save it to an encrypted cookie
|
||||
const encryptedCodeVerifier = await jwt.encode({
|
||||
...options.jwt,
|
||||
maxAge,
|
||||
token: { code_verifier },
|
||||
})
|
||||
|
||||
logger.debug("CREATE_PKCE_CHALLENGE_VERIFIER", {
|
||||
code_challenge,
|
||||
code_challenge_method: PKCE_CODE_CHALLENGE_METHOD,
|
||||
code_verifier,
|
||||
maxAge,
|
||||
})
|
||||
|
||||
return {
|
||||
code_challenge,
|
||||
code_challenge_method: PKCE_CODE_CHALLENGE_METHOD,
|
||||
cookie: {
|
||||
name: cookies.pkceCodeVerifier.name,
|
||||
value: encryptedCodeVerifier,
|
||||
options: { ...cookies.pkceCodeVerifier.options, expires },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns code_verifier if provider uses PKCE,
|
||||
* and clears the container cookie afterwards.
|
||||
*/
|
||||
export async function usePKCECodeVerifier(
|
||||
codeVerifier: string | undefined,
|
||||
options: InternalOptions<"oauth">
|
||||
): Promise<{ codeVerifier: string; cookie: Cookie } | undefined> {
|
||||
const { cookies, provider } = options
|
||||
|
||||
if (!provider?.checks?.includes("pkce") || !codeVerifier) {
|
||||
return
|
||||
}
|
||||
|
||||
const pkce = (await jwt.decode({
|
||||
...options.jwt,
|
||||
token: codeVerifier,
|
||||
})) as any
|
||||
|
||||
return {
|
||||
codeVerifier: pkce?.value ?? undefined,
|
||||
cookie: {
|
||||
name: cookies.pkceCodeVerifier.name,
|
||||
value: "",
|
||||
options: { ...cookies.pkceCodeVerifier.options, maxAge: 0 },
|
||||
},
|
||||
}
|
||||
}
|
||||
63
packages/core/src/lib/oauth/state-handler.ts
Normal file
63
packages/core/src/lib/oauth/state-handler.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import type { InternalOptions } from "../.."
|
||||
import type { Cookie } from "../cookie"
|
||||
import * as o from "oauth4webapi"
|
||||
|
||||
const STATE_MAX_AGE = 60 * 15 // 15 minutes in seconds
|
||||
|
||||
/** Returns state if the provider supports it */
|
||||
export async function createState(
|
||||
options: InternalOptions<"oauth">
|
||||
): Promise<{ cookie: Cookie; value: string } | undefined> {
|
||||
const { logger, provider, jwt, cookies } = options
|
||||
|
||||
if (!provider.checks?.includes("state")) {
|
||||
// Provider does not support state, return nothing
|
||||
return
|
||||
}
|
||||
|
||||
const state = o.generateRandomState()
|
||||
const maxAge = cookies.state.options.maxAge ?? STATE_MAX_AGE
|
||||
|
||||
const encodedState = await jwt.encode({
|
||||
...jwt,
|
||||
maxAge,
|
||||
token: { state },
|
||||
})
|
||||
|
||||
logger.debug("CREATE_STATE", { state, maxAge })
|
||||
|
||||
const expires = new Date()
|
||||
expires.setTime(expires.getTime() + maxAge * 1000)
|
||||
return {
|
||||
value: state,
|
||||
cookie: {
|
||||
name: cookies.state.name,
|
||||
value: encodedState,
|
||||
options: { ...cookies.state.options, expires },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns state from if the provider supports states,
|
||||
* and clears the container cookie afterwards.
|
||||
*/
|
||||
export async function useState(
|
||||
state: string | undefined,
|
||||
options: InternalOptions<"oauth">
|
||||
): Promise<{ value: string; cookie: Cookie } | undefined> {
|
||||
const { cookies, provider, jwt } = options
|
||||
|
||||
if (!provider.checks?.includes("state") || !state) return
|
||||
|
||||
const value = (await jwt.decode({ ...options.jwt, token: state })) as any
|
||||
|
||||
return {
|
||||
value: value?.value ?? undefined,
|
||||
cookie: {
|
||||
name: cookies.state.name,
|
||||
value: "",
|
||||
options: { ...cookies.pkceCodeVerifier.options, maxAge: 0 },
|
||||
},
|
||||
}
|
||||
}
|
||||
113
packages/core/src/lib/pages/error.tsx
Normal file
113
packages/core/src/lib/pages/error.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import type { Theme } from "../.."
|
||||
|
||||
/**
|
||||
* The following errors are passed as error query parameters to the default or overridden error page.
|
||||
*
|
||||
* [Documentation](https://next-auth.js.org/configuration/pages#error-page) */
|
||||
export type ErrorType =
|
||||
| "default"
|
||||
| "configuration"
|
||||
| "accessdenied"
|
||||
| "verification"
|
||||
|
||||
export interface ErrorProps {
|
||||
url?: URL
|
||||
theme?: Theme
|
||||
error?: ErrorType
|
||||
}
|
||||
|
||||
interface ErrorView {
|
||||
status: number
|
||||
heading: string
|
||||
message: JSX.Element
|
||||
signin?: JSX.Element
|
||||
}
|
||||
|
||||
/** Renders an error page. */
|
||||
export default function ErrorPage(props: ErrorProps) {
|
||||
const { url, error = "default", theme } = props
|
||||
const signinPageUrl = `${url}/signin`
|
||||
|
||||
const errors: Record<ErrorType, ErrorView> = {
|
||||
default: {
|
||||
status: 200,
|
||||
heading: "Error",
|
||||
message: (
|
||||
<p>
|
||||
<a className="site" href={url?.origin}>
|
||||
{url?.host}
|
||||
</a>
|
||||
</p>
|
||||
),
|
||||
},
|
||||
configuration: {
|
||||
status: 500,
|
||||
heading: "Server error",
|
||||
message: (
|
||||
<div>
|
||||
<p>There is a problem with the server configuration.</p>
|
||||
<p>Check the server logs for more information.</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
accessdenied: {
|
||||
status: 403,
|
||||
heading: "Access Denied",
|
||||
message: (
|
||||
<div>
|
||||
<p>You do not have permission to sign in.</p>
|
||||
<p>
|
||||
<a className="button" href={signinPageUrl}>
|
||||
Sign in
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
verification: {
|
||||
status: 403,
|
||||
heading: "Unable to sign in",
|
||||
message: (
|
||||
<div>
|
||||
<p>The sign in link is no longer valid.</p>
|
||||
<p>It may have been used already or it may have expired.</p>
|
||||
</div>
|
||||
),
|
||||
signin: (
|
||||
<p>
|
||||
<a className="button" href={signinPageUrl}>
|
||||
Sign in
|
||||
</a>
|
||||
</p>
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
const { status, heading, message, signin } =
|
||||
errors[error.toLowerCase()] ?? errors.default
|
||||
|
||||
return {
|
||||
status,
|
||||
html: (
|
||||
<div className="error">
|
||||
{theme?.brandColor && (
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
:root {
|
||||
--brand-color: ${theme?.brandColor}
|
||||
}
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{theme?.logo && <img src={theme.logo} alt="Logo" className="logo" />}
|
||||
<div className="card">
|
||||
<h1>{heading}</h1>
|
||||
<div className="message">{message}</div>
|
||||
{signin}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
}
|
||||
87
packages/core/src/lib/pages/index.ts
Normal file
87
packages/core/src/lib/pages/index.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import renderToString from "preact-render-to-string"
|
||||
import css from "../styles"
|
||||
import ErrorPage from "./error"
|
||||
import SigninPage from "./signin"
|
||||
import SignoutPage from "./signout"
|
||||
import VerifyRequestPage from "./verify-request"
|
||||
|
||||
import type { InternalOptions, RequestInternal, ResponseInternal } from "../.."
|
||||
import type { Cookie } from "../cookie"
|
||||
import type { ErrorType } from "./error"
|
||||
|
||||
type RenderPageParams = {
|
||||
query?: RequestInternal["query"]
|
||||
cookies?: Cookie[]
|
||||
} & Partial<
|
||||
Pick<
|
||||
InternalOptions,
|
||||
"url" | "callbackUrl" | "csrfToken" | "providers" | "theme"
|
||||
>
|
||||
>
|
||||
|
||||
/**
|
||||
* Unless the user defines their [own pages](https://next-auth.js.org/configuration/pages),
|
||||
* we render a set of default ones, using Preact SSR.
|
||||
*/
|
||||
export default function renderPage(params: RenderPageParams) {
|
||||
const { url, theme, query, cookies } = params
|
||||
|
||||
function send({ html, title, status }: any): ResponseInternal {
|
||||
return {
|
||||
cookies,
|
||||
status,
|
||||
headers: { "Content-Type": "text/html" },
|
||||
body: `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width, initial-scale=1.0"><style>${css}</style><title>${title}</title></head><body class="__next-auth-theme-${
|
||||
theme?.colorScheme ?? "auto"
|
||||
}"><div class="page">${renderToString(html)}</div></body></html>`,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
signin(props?: any) {
|
||||
return send({
|
||||
html: SigninPage({
|
||||
csrfToken: params.csrfToken,
|
||||
// We only want to render providers
|
||||
providers: params.providers?.filter(
|
||||
(provider) =>
|
||||
// Always render oauth and email type providers
|
||||
["email", "oauth", "oidc"].includes(provider.type) ||
|
||||
// Only render credentials type provider if credentials are defined
|
||||
(provider.type === "credentials" && provider.credentials) ||
|
||||
// Don't render other provider types
|
||||
false
|
||||
),
|
||||
callbackUrl: params.callbackUrl,
|
||||
theme,
|
||||
...query,
|
||||
...props,
|
||||
}),
|
||||
title: "Sign In",
|
||||
})
|
||||
},
|
||||
signout(props?: any) {
|
||||
return send({
|
||||
html: SignoutPage({
|
||||
csrfToken: params.csrfToken,
|
||||
url,
|
||||
theme,
|
||||
...props,
|
||||
}),
|
||||
title: "Sign Out",
|
||||
})
|
||||
},
|
||||
verifyRequest(props?: any) {
|
||||
return send({
|
||||
html: VerifyRequestPage({ url, theme, ...props }),
|
||||
title: "Verify Request",
|
||||
})
|
||||
},
|
||||
error(props?: { error?: ErrorType }) {
|
||||
return send({
|
||||
...ErrorPage({ url, theme, ...props }),
|
||||
title: "Error",
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
184
packages/core/src/lib/pages/signin.tsx
Normal file
184
packages/core/src/lib/pages/signin.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
import type { InternalProvider, Theme } from "../.."
|
||||
import type { CSSProperties } from "react"
|
||||
|
||||
/**
|
||||
* The following errors are passed as error query parameters to the default or overridden sign-in page.
|
||||
*
|
||||
* [Documentation](https://next-auth.js.org/configuration/pages#sign-in-page) */
|
||||
export type SignInErrorTypes =
|
||||
| "Signin"
|
||||
| "OAuthSignin"
|
||||
| "OAuthCallback"
|
||||
| "OAuthCreateAccount"
|
||||
| "EmailCreateAccount"
|
||||
| "Callback"
|
||||
| "OAuthAccountNotLinked"
|
||||
| "EmailSignin"
|
||||
| "CredentialsSignin"
|
||||
| "SessionRequired"
|
||||
| "default"
|
||||
|
||||
export interface SignInServerPageParams {
|
||||
csrfToken: string
|
||||
providers: InternalProvider[]
|
||||
callbackUrl: string
|
||||
email: string
|
||||
error: SignInErrorTypes
|
||||
theme: Theme
|
||||
}
|
||||
|
||||
export default function SigninPage(props: SignInServerPageParams) {
|
||||
const {
|
||||
csrfToken,
|
||||
providers = [],
|
||||
callbackUrl,
|
||||
theme,
|
||||
email,
|
||||
error: errorType,
|
||||
} = props
|
||||
|
||||
if (typeof document !== "undefined" && theme.brandColor) {
|
||||
document.documentElement.style.setProperty(
|
||||
"--brand-color",
|
||||
theme.brandColor
|
||||
)
|
||||
}
|
||||
|
||||
const errors: Record<SignInErrorTypes, string> = {
|
||||
Signin: "Try signing in with a different account.",
|
||||
OAuthSignin: "Try signing in with a different account.",
|
||||
OAuthCallback: "Try signing in with a different account.",
|
||||
OAuthCreateAccount: "Try signing in with a different account.",
|
||||
EmailCreateAccount: "Try signing in with a different account.",
|
||||
Callback: "Try signing in with a different account.",
|
||||
OAuthAccountNotLinked:
|
||||
"To confirm your identity, sign in with the same account you used originally.",
|
||||
EmailSignin: "The e-mail could not be sent.",
|
||||
CredentialsSignin:
|
||||
"Sign in failed. Check the details you provided are correct.",
|
||||
SessionRequired: "Please sign in to access this page.",
|
||||
default: "Unable to sign in.",
|
||||
}
|
||||
|
||||
const error = errorType && (errors[errorType] ?? errors.default)
|
||||
|
||||
// TODO: move logos
|
||||
const logos =
|
||||
"https://raw.githubusercontent.com/nextauthjs/next-auth/main/packages/next-auth/provider-logos"
|
||||
return (
|
||||
<div className="signin">
|
||||
{theme.brandColor && (
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `:root {--brand-color: ${theme.brandColor}}`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{theme.logo && <img src={theme.logo} alt="Logo" className="logo" />}
|
||||
<div className="card">
|
||||
{error && (
|
||||
<div className="error">
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
)}
|
||||
{providers.map((provider, i) => (
|
||||
<div key={provider.id} className="provider">
|
||||
{provider.type === "oauth" || provider.type === "oidc" ? (
|
||||
<form action={provider.signinUrl} method="POST">
|
||||
<input type="hidden" name="csrfToken" value={csrfToken} />
|
||||
{callbackUrl && (
|
||||
<input type="hidden" name="callbackUrl" value={callbackUrl} />
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
className="button"
|
||||
style={
|
||||
// eslint-disable-next-line
|
||||
{
|
||||
"--provider-bg": provider.style?.bg ?? "",
|
||||
"--provider-dark-bg": provider.style?.bgDark ?? "",
|
||||
"--provider-color": provider.style?.text ?? "",
|
||||
"--provider-dark-color": provider.style?.textDark ?? "",
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
{provider.style?.logo && (
|
||||
<img
|
||||
id="provider-logo"
|
||||
src={`${
|
||||
provider.style.logo.startsWith("/") ? logos : ""
|
||||
}${provider.style.logo}`}
|
||||
/>
|
||||
)}
|
||||
{provider.style?.logoDark && (
|
||||
<img
|
||||
id="provider-logo-dark"
|
||||
src={`${
|
||||
provider.style.logo.startsWith("/") ? logos : ""
|
||||
}${provider.style.logoDark}`}
|
||||
/>
|
||||
)}
|
||||
<span>Sign in with {provider.name}</span>
|
||||
</button>
|
||||
</form>
|
||||
) : null}
|
||||
{(provider.type === "email" || provider.type === "credentials") &&
|
||||
i > 0 &&
|
||||
providers[i - 1].type !== "email" &&
|
||||
providers[i - 1].type !== "credentials" && <hr />}
|
||||
{provider.type === "email" && (
|
||||
<form action={provider.signinUrl} method="POST">
|
||||
<input type="hidden" name="csrfToken" value={csrfToken} />
|
||||
<label
|
||||
className="section-header"
|
||||
htmlFor={`input-email-for-${provider.id}-provider`}
|
||||
>
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id={`input-email-for-${provider.id}-provider`}
|
||||
autoFocus
|
||||
type="email"
|
||||
name="email"
|
||||
value={email}
|
||||
placeholder="email@example.com"
|
||||
required
|
||||
/>
|
||||
<button type="submit">Sign in with {provider.name}</button>
|
||||
</form>
|
||||
)}
|
||||
{provider.type === "credentials" && (
|
||||
<form action={provider.callbackUrl} method="POST">
|
||||
<input type="hidden" name="csrfToken" value={csrfToken} />
|
||||
{Object.keys(provider.credentials).map((credential) => {
|
||||
return (
|
||||
<div key={`input-group-${provider.id}`}>
|
||||
<label
|
||||
className="section-header"
|
||||
htmlFor={`input-${credential}-for-${provider.id}-provider`}
|
||||
>
|
||||
{provider.credentials[credential].label ?? credential}
|
||||
</label>
|
||||
<input
|
||||
name={credential}
|
||||
id={`input-${credential}-for-${provider.id}-provider`}
|
||||
type={provider.credentials[credential].type ?? "text"}
|
||||
placeholder={
|
||||
provider.credentials[credential].placeholder ?? ""
|
||||
}
|
||||
{...provider.credentials[credential]}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<button type="submit">Sign in with {provider.name}</button>
|
||||
</form>
|
||||
)}
|
||||
{(provider.type === "email" || provider.type === "credentials") &&
|
||||
i + 1 < providers.length && <hr />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
36
packages/core/src/lib/pages/signout.tsx
Normal file
36
packages/core/src/lib/pages/signout.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { Theme } from "../.."
|
||||
|
||||
export interface SignoutProps {
|
||||
url: URL
|
||||
csrfToken: string
|
||||
theme: Theme
|
||||
}
|
||||
|
||||
export default function SignoutPage(props: SignoutProps) {
|
||||
const { url, csrfToken, theme } = props
|
||||
|
||||
return (
|
||||
<div className="signout">
|
||||
{theme.brandColor && (
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
:root {
|
||||
--brand-color: ${theme.brandColor}
|
||||
}
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{theme.logo && <img src={theme.logo} alt="Logo" className="logo" />}
|
||||
<div className="card">
|
||||
<h1>Signout</h1>
|
||||
<p>Are you sure you want to sign out?</p>
|
||||
<form action={`${url}/signout`} method="POST">
|
||||
<input type="hidden" name="csrfToken" value={csrfToken} />
|
||||
<button type="submit">Sign out</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
36
packages/core/src/lib/pages/verify-request.tsx
Normal file
36
packages/core/src/lib/pages/verify-request.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { Theme } from "../.."
|
||||
|
||||
interface VerifyRequestPageProps {
|
||||
url: URL
|
||||
theme: Theme
|
||||
}
|
||||
|
||||
export default function VerifyRequestPage(props: VerifyRequestPageProps) {
|
||||
const { url, theme } = props
|
||||
|
||||
return (
|
||||
<div className="verify-request">
|
||||
{theme.brandColor && (
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
:root {
|
||||
--brand-color: ${theme.brandColor}
|
||||
}
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{theme.logo && <img src={theme.logo} alt="Logo" className="logo" />}
|
||||
<div className="card">
|
||||
<h1>Check your email</h1>
|
||||
<p>A sign in link has been sent to your email address.</p>
|
||||
<p>
|
||||
<a className="site" href={url.origin}>
|
||||
{url.host}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
101
packages/core/src/lib/providers.ts
Normal file
101
packages/core/src/lib/providers.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { merge } from "./utils/merge"
|
||||
|
||||
import type { InternalProvider } from ".."
|
||||
import type {
|
||||
OAuthConfig,
|
||||
OAuthConfigInternal,
|
||||
OAuthEndpointType,
|
||||
OAuthUserConfig,
|
||||
Provider,
|
||||
} from "../providers"
|
||||
|
||||
/**
|
||||
* Adds `signinUrl` and `callbackUrl` to each provider
|
||||
* and deep merge user-defined options.
|
||||
*/
|
||||
export default function parseProviders(params: {
|
||||
providers: Provider[]
|
||||
url: URL
|
||||
providerId?: string
|
||||
}): {
|
||||
providers: InternalProvider[]
|
||||
provider?: InternalProvider
|
||||
} {
|
||||
const { url, providerId } = params
|
||||
|
||||
const providers = params.providers.map((provider) => {
|
||||
const { options: userOptions, ...defaults } = provider
|
||||
|
||||
const id = (userOptions?.id ?? defaults.id) as string
|
||||
const merged = merge(defaults, userOptions, {
|
||||
signinUrl: `${url}/signin/${id}`,
|
||||
callbackUrl: `${url}/callback/${id}`,
|
||||
})
|
||||
|
||||
if (provider.type === "oauth" || provider.type === "oidc") {
|
||||
return normalizeOAuth(merged)
|
||||
}
|
||||
|
||||
return merged
|
||||
})
|
||||
|
||||
return {
|
||||
providers,
|
||||
provider: providers.find(({ id }) => id === providerId),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeOAuth(
|
||||
c?: OAuthConfig<any> | OAuthUserConfig<any>
|
||||
): OAuthConfigInternal<any> | {} {
|
||||
if (!c) return {}
|
||||
|
||||
if (c.issuer) c.wellKnown ??= `${c.issuer}/.well-known/openid-configuration`
|
||||
|
||||
const authorization = normalizeEndpoint(c.authorization, c.issuer)
|
||||
if (authorization && !authorization.url?.searchParams.has("scope")) {
|
||||
authorization.url.searchParams.set("scope", "openid profile email")
|
||||
}
|
||||
|
||||
const token = normalizeEndpoint(c.token, c.issuer)
|
||||
|
||||
const userinfo = normalizeEndpoint(c.userinfo, c.issuer)
|
||||
|
||||
return {
|
||||
...c,
|
||||
authorization,
|
||||
token,
|
||||
checks: c.checks ?? ["pkce"],
|
||||
userinfo,
|
||||
profile: c.profile ?? defaultProfile,
|
||||
}
|
||||
}
|
||||
|
||||
function defaultProfile(profile: any) {
|
||||
return {
|
||||
id: profile.sub ?? profile.id,
|
||||
name:
|
||||
profile.name ?? profile.nickname ?? profile.preferred_username ?? null,
|
||||
email: profile.email ?? null,
|
||||
image: profile.picture ?? null,
|
||||
}
|
||||
}
|
||||
function normalizeEndpoint(
|
||||
e?: OAuthConfig<any>[OAuthEndpointType],
|
||||
issuer?: string
|
||||
): OAuthConfigInternal<any>[OAuthEndpointType] {
|
||||
if (!e || issuer) return
|
||||
if (typeof e === "string") {
|
||||
return { url: new URL(e) }
|
||||
}
|
||||
// If v.url is undefined, it's because the provider config
|
||||
// assumes that we will use the issuer endpoint.
|
||||
// The existence of either v.url or provider.issuer is checked in
|
||||
// assert.ts
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const url = new URL(e.url!)
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
|
||||
for (const k in e.params) url.searchParams.set(k, e.params[k] as any)
|
||||
|
||||
return { url }
|
||||
}
|
||||
420
packages/core/src/lib/routes/callback.ts
Normal file
420
packages/core/src/lib/routes/callback.ts
Normal file
@@ -0,0 +1,420 @@
|
||||
import callbackHandler from "../callback-handler"
|
||||
import getAdapterUserFromEmail from "../email/getUserFromEmail"
|
||||
import { handleOAuthCallback } from "../oauth/callback"
|
||||
import { createHash } from "../web"
|
||||
|
||||
import type { RequestInternal, ResponseInternal, User } from "../.."
|
||||
import type { AdapterSession } from "../../adapters"
|
||||
import type { Cookie, SessionStore } from "../cookie"
|
||||
import type { InternalOptions } from "../types"
|
||||
|
||||
/** Handle callbacks from login services */
|
||||
export async function callback(params: {
|
||||
options: InternalOptions
|
||||
query: RequestInternal["query"]
|
||||
method: Required<RequestInternal>["method"]
|
||||
body: RequestInternal["body"]
|
||||
headers: RequestInternal["headers"]
|
||||
cookies: RequestInternal["cookies"]
|
||||
sessionStore: SessionStore
|
||||
}): Promise<ResponseInternal> {
|
||||
const { options, query, body, method, headers, sessionStore } = params
|
||||
const {
|
||||
provider,
|
||||
adapter,
|
||||
url,
|
||||
callbackUrl,
|
||||
pages,
|
||||
jwt,
|
||||
events,
|
||||
callbacks,
|
||||
session: { strategy: sessionStrategy, maxAge: sessionMaxAge },
|
||||
logger,
|
||||
} = options
|
||||
|
||||
const cookies: Cookie[] = []
|
||||
|
||||
const useJwtSession = sessionStrategy === "jwt"
|
||||
|
||||
if (provider.type === "oauth" || provider.type === "oidc") {
|
||||
try {
|
||||
const {
|
||||
profile,
|
||||
account,
|
||||
OAuthProfile,
|
||||
cookies: oauthCookies,
|
||||
} = await handleOAuthCallback({
|
||||
query,
|
||||
body,
|
||||
options,
|
||||
cookies: params.cookies,
|
||||
})
|
||||
|
||||
if (oauthCookies.length) cookies.push(...oauthCookies)
|
||||
|
||||
try {
|
||||
// Make it easier to debug when adding a new provider
|
||||
logger.debug("OAUTH_CALLBACK_RESPONSE", {
|
||||
profile,
|
||||
account,
|
||||
OAuthProfile,
|
||||
})
|
||||
|
||||
// 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.
|
||||
//
|
||||
// Note: In oAuthCallback an error is logged with debug info, so it
|
||||
// should at least be visible to developers what happened if it is an
|
||||
// error with the provider.
|
||||
if (!profile || !account || !OAuthProfile) {
|
||||
return { redirect: `${url}/signin`, cookies }
|
||||
}
|
||||
|
||||
// Check if user is allowed to sign in
|
||||
// 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
|
||||
if (adapter) {
|
||||
const { getUserByAccount } = adapter
|
||||
const userByAccount = await getUserByAccount({
|
||||
providerAccountId: account.providerAccountId,
|
||||
provider: provider.id,
|
||||
})
|
||||
|
||||
if (userByAccount) userOrProfile = userByAccount
|
||||
}
|
||||
|
||||
try {
|
||||
const isAllowed = await callbacks.signIn({
|
||||
user: userOrProfile,
|
||||
account,
|
||||
profile: OAuthProfile,
|
||||
})
|
||||
if (!isAllowed) {
|
||||
return { redirect: `${url}/error?error=AccessDenied`, cookies }
|
||||
} else if (typeof isAllowed === "string") {
|
||||
return { redirect: isAllowed, cookies }
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
redirect: `${url}/error?error=${encodeURIComponent(
|
||||
(error as Error).message
|
||||
)}`,
|
||||
cookies,
|
||||
}
|
||||
}
|
||||
|
||||
// Sign user in
|
||||
const { user, session, isNewUser } = await callbackHandler({
|
||||
sessionToken: sessionStore.value,
|
||||
profile,
|
||||
account,
|
||||
options,
|
||||
})
|
||||
|
||||
if (useJwtSession) {
|
||||
const defaultToken = {
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
picture: user.image,
|
||||
sub: user.id?.toString(),
|
||||
}
|
||||
const token = await callbacks.jwt({
|
||||
token: defaultToken,
|
||||
user,
|
||||
account,
|
||||
profile: OAuthProfile,
|
||||
isNewUser,
|
||||
})
|
||||
|
||||
// Encode token
|
||||
const newToken = await jwt.encode({ ...jwt, token })
|
||||
|
||||
// Set cookie expiry date
|
||||
const cookieExpires = new Date()
|
||||
cookieExpires.setTime(cookieExpires.getTime() + sessionMaxAge * 1000)
|
||||
|
||||
const sessionCookies = sessionStore.chunk(newToken, {
|
||||
expires: cookieExpires,
|
||||
})
|
||||
cookies.push(...sessionCookies)
|
||||
} else {
|
||||
// Save Session Token in cookie
|
||||
cookies.push({
|
||||
name: options.cookies.sessionToken.name,
|
||||
value: (session as AdapterSession).sessionToken,
|
||||
options: {
|
||||
...options.cookies.sessionToken.options,
|
||||
expires: (session as AdapterSession).expires,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// @ts-expect-error
|
||||
await events.signIn?.({ user, account, profile, isNewUser })
|
||||
|
||||
// Handle first logins on new accounts
|
||||
// e.g. option to send users to a new account landing page on initial login
|
||||
// Note that the callback URL is preserved, so the journey can still be resumed
|
||||
if (isNewUser && pages.newUser) {
|
||||
return {
|
||||
redirect: `${pages.newUser}${
|
||||
pages.newUser.includes("?") ? "&" : "?"
|
||||
}callbackUrl=${encodeURIComponent(callbackUrl)}`,
|
||||
cookies,
|
||||
}
|
||||
}
|
||||
|
||||
// Callback URL is already verified at this point, so safe to use if specified
|
||||
return { redirect: callbackUrl, cookies }
|
||||
} catch (error) {
|
||||
if ((error as Error).name === "AccountNotLinkedError") {
|
||||
// If the email on the account is already linked, but not with this OAuth account
|
||||
return {
|
||||
redirect: `${url}/error?error=OAuthAccountNotLinked`,
|
||||
cookies,
|
||||
}
|
||||
} else if ((error as Error).name === "CreateUserError") {
|
||||
return { redirect: `${url}/error?error=OAuthCreateAccount`, cookies }
|
||||
}
|
||||
logger.error("OAUTH_CALLBACK_HANDLER_ERROR", error as Error)
|
||||
return { redirect: `${url}/error?error=Callback`, cookies }
|
||||
}
|
||||
} catch (error) {
|
||||
if ((error as Error).name === "OAuthCallbackError") {
|
||||
logger.error("OAUTH_CALLBACK_ERROR", {
|
||||
error: error as Error,
|
||||
providerId: provider.id,
|
||||
})
|
||||
return { redirect: `${url}/error?error=OAuthCallback`, cookies }
|
||||
}
|
||||
logger.error("OAUTH_CALLBACK_ERROR", error as Error)
|
||||
return { redirect: `${url}/error?error=Callback`, cookies }
|
||||
}
|
||||
} else if (provider.type === "email") {
|
||||
try {
|
||||
const token = query?.token as string | undefined
|
||||
const identifier = query?.email as string | undefined
|
||||
|
||||
// If these are missing, the sign-in URL was manually opened without these params or the `sendVerificationRequest` method did not send the link correctly in the email.
|
||||
if (!token || !identifier) {
|
||||
return { redirect: `${url}/error?error=configuration`, cookies }
|
||||
}
|
||||
|
||||
const secret = provider.secret ?? options.secret
|
||||
// @ts-expect-error -- Verified in `assertConfig`. adapter: Adapter<true>
|
||||
const invite = await adapter.useVerificationToken({
|
||||
identifier,
|
||||
token: await createHash(`${token}${secret}`),
|
||||
})
|
||||
|
||||
const invalidInvite = !invite || invite.expires.valueOf() < Date.now()
|
||||
if (invalidInvite) {
|
||||
return { redirect: `${url}/error?error=Verification`, cookies }
|
||||
}
|
||||
|
||||
const profile = await getAdapterUserFromEmail({
|
||||
email: identifier,
|
||||
// @ts-expect-error -- Verified in `assertConfig`. adapter: Adapter<true>
|
||||
adapter,
|
||||
})
|
||||
|
||||
const account = {
|
||||
providerAccountId: profile.email,
|
||||
type: "email" as const,
|
||||
provider: provider.id,
|
||||
}
|
||||
|
||||
// Check if user is allowed to sign in
|
||||
try {
|
||||
const signInCallbackResponse = await callbacks.signIn({
|
||||
user: profile,
|
||||
account,
|
||||
})
|
||||
if (!signInCallbackResponse) {
|
||||
return { redirect: `${url}/error?error=AccessDenied`, cookies }
|
||||
} else if (typeof signInCallbackResponse === "string") {
|
||||
return { redirect: signInCallbackResponse, cookies }
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
redirect: `${url}/error?error=${encodeURIComponent(
|
||||
(error as Error).message
|
||||
)}`,
|
||||
cookies,
|
||||
}
|
||||
}
|
||||
|
||||
// Sign user in
|
||||
const { user, session, isNewUser } = await callbackHandler({
|
||||
sessionToken: sessionStore.value,
|
||||
profile,
|
||||
account,
|
||||
options,
|
||||
})
|
||||
|
||||
if (useJwtSession) {
|
||||
const defaultToken = {
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
picture: user.image,
|
||||
sub: user.id?.toString(),
|
||||
}
|
||||
const token = await callbacks.jwt({
|
||||
token: defaultToken,
|
||||
user,
|
||||
account,
|
||||
isNewUser,
|
||||
})
|
||||
|
||||
// Encode token
|
||||
const newToken = await jwt.encode({ ...jwt, token })
|
||||
|
||||
// Set cookie expiry date
|
||||
const cookieExpires = new Date()
|
||||
cookieExpires.setTime(cookieExpires.getTime() + sessionMaxAge * 1000)
|
||||
|
||||
const sessionCookies = sessionStore.chunk(newToken, {
|
||||
expires: cookieExpires,
|
||||
})
|
||||
cookies.push(...sessionCookies)
|
||||
} else {
|
||||
// Save Session Token in cookie
|
||||
cookies.push({
|
||||
name: options.cookies.sessionToken.name,
|
||||
value: (session as AdapterSession).sessionToken,
|
||||
options: {
|
||||
...options.cookies.sessionToken.options,
|
||||
expires: (session as AdapterSession).expires,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
await events.signIn?.({ user, account, isNewUser })
|
||||
|
||||
// Handle first logins on new accounts
|
||||
// e.g. option to send users to a new account landing page on initial login
|
||||
// Note that the callback URL is preserved, so the journey can still be resumed
|
||||
if (isNewUser && pages.newUser) {
|
||||
return {
|
||||
redirect: `${pages.newUser}${
|
||||
pages.newUser.includes("?") ? "&" : "?"
|
||||
}callbackUrl=${encodeURIComponent(callbackUrl)}`,
|
||||
cookies,
|
||||
}
|
||||
}
|
||||
|
||||
// Callback URL is already verified at this point, so safe to use if specified
|
||||
return { redirect: callbackUrl, cookies }
|
||||
} catch (error) {
|
||||
if ((error as Error).name === "CreateUserError") {
|
||||
return { redirect: `${url}/error?error=EmailCreateAccount`, cookies }
|
||||
}
|
||||
logger.error("CALLBACK_EMAIL_ERROR", error as Error)
|
||||
return { redirect: `${url}/error?error=Callback`, cookies }
|
||||
}
|
||||
} else if (provider.type === "credentials" && method === "POST") {
|
||||
const credentials = body
|
||||
|
||||
let user: User | null
|
||||
try {
|
||||
user = await provider.authorize(credentials, {
|
||||
query,
|
||||
body,
|
||||
headers,
|
||||
method,
|
||||
})
|
||||
if (!user) {
|
||||
return {
|
||||
status: 401,
|
||||
redirect: `${url}/error?${new URLSearchParams({
|
||||
error: "CredentialsSignin",
|
||||
provider: provider.id,
|
||||
})}`,
|
||||
cookies,
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 401,
|
||||
redirect: `${url}/error?error=${encodeURIComponent(
|
||||
(error as Error).message
|
||||
)}`,
|
||||
cookies,
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {import("src").Account} */
|
||||
const account = {
|
||||
providerAccountId: user.id,
|
||||
type: "credentials",
|
||||
provider: provider.id,
|
||||
}
|
||||
|
||||
try {
|
||||
const isAllowed = await callbacks.signIn({
|
||||
user,
|
||||
// @ts-expect-error
|
||||
account,
|
||||
credentials,
|
||||
})
|
||||
if (!isAllowed) {
|
||||
return {
|
||||
status: 403,
|
||||
redirect: `${url}/error?error=AccessDenied`,
|
||||
cookies,
|
||||
}
|
||||
} else if (typeof isAllowed === "string") {
|
||||
return { redirect: isAllowed, cookies }
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
redirect: `${url}/error?error=${encodeURIComponent(
|
||||
(error as Error).message
|
||||
)}`,
|
||||
cookies,
|
||||
}
|
||||
}
|
||||
|
||||
const defaultToken = {
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
picture: user.image,
|
||||
sub: user.id?.toString(),
|
||||
}
|
||||
|
||||
const token = await callbacks.jwt({
|
||||
token: defaultToken,
|
||||
user,
|
||||
// @ts-expect-error
|
||||
account,
|
||||
isNewUser: false,
|
||||
})
|
||||
|
||||
// Encode token
|
||||
const newToken = await jwt.encode({ ...jwt, token })
|
||||
|
||||
// Set cookie expiry date
|
||||
const cookieExpires = new Date()
|
||||
cookieExpires.setTime(cookieExpires.getTime() + sessionMaxAge * 1000)
|
||||
|
||||
const sessionCookies = sessionStore.chunk(newToken, {
|
||||
expires: cookieExpires,
|
||||
})
|
||||
|
||||
cookies.push(...sessionCookies)
|
||||
|
||||
// @ts-expect-error
|
||||
await events.signIn?.({ user, account })
|
||||
|
||||
return { redirect: callbackUrl, cookies }
|
||||
}
|
||||
return {
|
||||
status: 500,
|
||||
body: `Error: Callback for provider type ${provider.type} not supported`,
|
||||
cookies,
|
||||
}
|
||||
}
|
||||
5
packages/core/src/lib/routes/index.ts
Normal file
5
packages/core/src/lib/routes/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { callback } from "./callback"
|
||||
export { providers } from "./providers"
|
||||
export { session } from "./session"
|
||||
export { signin } from "./signin"
|
||||
export { signout } from "./signout"
|
||||
29
packages/core/src/lib/routes/providers.ts
Normal file
29
packages/core/src/lib/routes/providers.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { InternalProvider, ResponseInternal } from "../.."
|
||||
|
||||
export interface PublicProvider {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
signinUrl: string
|
||||
callbackUrl: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a JSON object with a list of all OAuth providers currently configured
|
||||
* and their signin and callback URLs. This makes it possible to automatically
|
||||
* generate buttons for all providers when rendering client side.
|
||||
*/
|
||||
export function providers(
|
||||
providers: InternalProvider[]
|
||||
): ResponseInternal<Record<string, PublicProvider>> {
|
||||
return {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: providers.reduce<Record<string, PublicProvider>>(
|
||||
(acc, { id, name, type, signinUrl, callbackUrl }) => {
|
||||
acc[id] = { id, name, type, signinUrl, callbackUrl }
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
),
|
||||
}
|
||||
}
|
||||
165
packages/core/src/lib/routes/session.ts
Normal file
165
packages/core/src/lib/routes/session.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { fromDate } from "../utils/date"
|
||||
|
||||
import type { InternalOptions, ResponseInternal, Session } from "../.."
|
||||
import type { Adapter } from "../../adapters"
|
||||
import type { SessionStore } from "../cookie"
|
||||
|
||||
interface SessionParams {
|
||||
options: InternalOptions
|
||||
sessionStore: SessionStore
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a session object (without any private fields)
|
||||
* for Single Page App clients
|
||||
*/
|
||||
|
||||
export async function session(
|
||||
params: SessionParams
|
||||
): Promise<ResponseInternal<Session | {}>> {
|
||||
const { options, sessionStore } = params
|
||||
const {
|
||||
adapter,
|
||||
jwt,
|
||||
events,
|
||||
callbacks,
|
||||
logger,
|
||||
session: { strategy: sessionStrategy, maxAge: sessionMaxAge },
|
||||
} = options
|
||||
|
||||
const response: ResponseInternal<Session | {}> = {
|
||||
body: {},
|
||||
headers: { "Content-Type": "application/json" },
|
||||
cookies: [],
|
||||
}
|
||||
|
||||
const sessionToken = sessionStore.value
|
||||
|
||||
if (!sessionToken) return response
|
||||
|
||||
if (sessionStrategy === "jwt") {
|
||||
try {
|
||||
const decodedToken = await jwt.decode({
|
||||
...jwt,
|
||||
token: sessionToken,
|
||||
})
|
||||
|
||||
const newExpires = fromDate(sessionMaxAge)
|
||||
|
||||
// By default, only exposes a limited subset of information to the client
|
||||
// as needed for presentation purposes (e.g. "you are logged in as...").
|
||||
const session = {
|
||||
user: {
|
||||
name: decodedToken?.name,
|
||||
email: decodedToken?.email,
|
||||
image: decodedToken?.picture,
|
||||
},
|
||||
expires: newExpires.toISOString(),
|
||||
}
|
||||
|
||||
// @ts-expect-error
|
||||
const token = await callbacks.jwt({ token: decodedToken })
|
||||
// @ts-expect-error
|
||||
const newSession = await callbacks.session({ session, token })
|
||||
|
||||
// Return session payload as response
|
||||
response.body = newSession
|
||||
|
||||
// Refresh JWT expiry by re-signing it, with an updated expiry date
|
||||
const newToken = await jwt.encode({
|
||||
...jwt,
|
||||
token,
|
||||
maxAge: options.session.maxAge,
|
||||
})
|
||||
|
||||
// Set cookie, to also update expiry date on cookie
|
||||
const sessionCookies = sessionStore.chunk(newToken, {
|
||||
expires: newExpires,
|
||||
})
|
||||
|
||||
response.cookies?.push(...sessionCookies)
|
||||
|
||||
await events.session?.({ session: newSession, token })
|
||||
} catch (error) {
|
||||
// If JWT not verifiable, make sure the cookie for it is removed and return empty object
|
||||
logger.error("JWT_SESSION_ERROR", error as Error)
|
||||
|
||||
response.cookies?.push(...sessionStore.clean())
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const { getSessionAndUser, deleteSession, updateSession } =
|
||||
adapter as Adapter
|
||||
let userAndSession = await getSessionAndUser(sessionToken)
|
||||
|
||||
// If session has expired, clean up the database
|
||||
if (
|
||||
userAndSession &&
|
||||
userAndSession.session.expires.valueOf() < Date.now()
|
||||
) {
|
||||
await deleteSession(sessionToken)
|
||||
userAndSession = null
|
||||
}
|
||||
|
||||
if (userAndSession) {
|
||||
const { user, session } = userAndSession
|
||||
|
||||
const sessionUpdateAge = options.session.updateAge
|
||||
// Calculate last updated date to throttle write updates to database
|
||||
// Formula: ({expiry date} - sessionMaxAge) + sessionUpdateAge
|
||||
// e.g. ({expiry date} - 30 days) + 1 hour
|
||||
const sessionIsDueToBeUpdatedDate =
|
||||
session.expires.valueOf() -
|
||||
sessionMaxAge * 1000 +
|
||||
sessionUpdateAge * 1000
|
||||
|
||||
const newExpires = fromDate(sessionMaxAge)
|
||||
// Trigger update of session expiry date and write to database, only
|
||||
// if the session was last updated more than {sessionUpdateAge} ago
|
||||
if (sessionIsDueToBeUpdatedDate <= Date.now()) {
|
||||
await updateSession({ sessionToken, expires: newExpires })
|
||||
}
|
||||
|
||||
// Pass Session through to the session callback
|
||||
// @ts-expect-error
|
||||
const sessionPayload = await callbacks.session({
|
||||
// By default, only exposes a limited subset of information to the client
|
||||
// as needed for presentation purposes (e.g. "you are logged in as...").
|
||||
session: {
|
||||
user: {
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
image: user.image,
|
||||
},
|
||||
expires: session.expires.toISOString(),
|
||||
},
|
||||
user,
|
||||
})
|
||||
|
||||
// Return session payload as response
|
||||
response.body = sessionPayload
|
||||
|
||||
// Set cookie again to update expiry
|
||||
response.cookies?.push({
|
||||
name: options.cookies.sessionToken.name,
|
||||
value: sessionToken,
|
||||
options: {
|
||||
...options.cookies.sessionToken.options,
|
||||
expires: newExpires,
|
||||
},
|
||||
})
|
||||
|
||||
// @ts-expect-error
|
||||
await events.session?.({ session: sessionPayload })
|
||||
} else if (sessionToken) {
|
||||
// If `sessionToken` was found set but it's not valid for a session then
|
||||
// remove the sessionToken cookie from browser.
|
||||
response.cookies?.push(...sessionStore.clean())
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("SESSION_ERROR", error as Error)
|
||||
}
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
103
packages/core/src/lib/routes/signin.ts
Normal file
103
packages/core/src/lib/routes/signin.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import getAdapterUserFromEmail from "../email/getUserFromEmail"
|
||||
import emailSignin from "../email/signin"
|
||||
import { getAuthorizationUrl } from "../oauth/authorization-url"
|
||||
|
||||
import type {
|
||||
Account,
|
||||
InternalOptions,
|
||||
RequestInternal,
|
||||
ResponseInternal,
|
||||
} from "../.."
|
||||
|
||||
/** Handle requests to /api/auth/signin */
|
||||
export async function signin(params: {
|
||||
options: InternalOptions<"oauth" | "email">
|
||||
query: RequestInternal["query"]
|
||||
body: RequestInternal["body"]
|
||||
}): Promise<ResponseInternal> {
|
||||
const { options, query, body } = params
|
||||
const { url, callbacks, logger, provider } = options
|
||||
|
||||
if (!provider.type) {
|
||||
return {
|
||||
status: 500,
|
||||
// @ts-expect-error
|
||||
text: `Error: Type not specified for ${provider.name}`,
|
||||
}
|
||||
}
|
||||
|
||||
if (provider.type === "oauth" || provider.type === "oidc") {
|
||||
try {
|
||||
return await getAuthorizationUrl({ options, query })
|
||||
} catch (error) {
|
||||
logger.error("SIGNIN_OAUTH_ERROR", {
|
||||
error: error as Error,
|
||||
providerId: provider.id,
|
||||
})
|
||||
return { redirect: `${url}/error?error=OAuthSignin` }
|
||||
}
|
||||
} else if (provider.type === "email") {
|
||||
let email: string = body?.email
|
||||
if (!email) return { redirect: `${url}/error?error=EmailSignin` }
|
||||
const normalizer: (identifier: string) => string =
|
||||
provider.normalizeIdentifier ??
|
||||
((identifier) => {
|
||||
// Get the first two elements only,
|
||||
// separated by `@` from user input.
|
||||
let [local, domain] = identifier.toLowerCase().trim().split("@")
|
||||
// The part before "@" can contain a ","
|
||||
// but we remove it on the domain part
|
||||
domain = domain.split(",")[0]
|
||||
return `${local}@${domain}`
|
||||
})
|
||||
|
||||
try {
|
||||
email = normalizer(body?.email)
|
||||
} catch (error) {
|
||||
logger.error("SIGNIN_EMAIL_ERROR", { error, providerId: provider.id })
|
||||
return { redirect: `${url}/error?error=EmailSignin` }
|
||||
}
|
||||
|
||||
const user = await getAdapterUserFromEmail({
|
||||
email,
|
||||
// @ts-expect-error -- Verified in `assertConfig`. adapter: Adapter<true>
|
||||
adapter: options.adapter,
|
||||
})
|
||||
|
||||
const account: Account = {
|
||||
providerAccountId: email,
|
||||
userId: email,
|
||||
type: "email",
|
||||
provider: provider.id,
|
||||
}
|
||||
|
||||
// Check if user is allowed to sign in
|
||||
try {
|
||||
const signInCallbackResponse = await callbacks.signIn({
|
||||
user,
|
||||
account,
|
||||
email: { verificationRequest: true },
|
||||
})
|
||||
if (!signInCallbackResponse) {
|
||||
return { redirect: `${url}/error?error=AccessDenied` }
|
||||
} else if (typeof signInCallbackResponse === "string") {
|
||||
return { redirect: signInCallbackResponse }
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
redirect: `${url}/error?${new URLSearchParams({
|
||||
error: error as string,
|
||||
})}`,
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const redirect = await emailSignin(email, options)
|
||||
return { redirect }
|
||||
} catch (error) {
|
||||
logger.error("SIGNIN_EMAIL_ERROR", { error, providerId: provider.id })
|
||||
return { redirect: `${url}/error?error=EmailSignin` }
|
||||
}
|
||||
}
|
||||
return { redirect: `${url}/signin` }
|
||||
}
|
||||
44
packages/core/src/lib/routes/signout.ts
Normal file
44
packages/core/src/lib/routes/signout.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { InternalOptions, ResponseInternal } from "../.."
|
||||
import type { Adapter } from "../../adapters"
|
||||
import type { SessionStore } from "../cookie"
|
||||
|
||||
/** Handle requests to /api/auth/signout */
|
||||
export async function signout(params: {
|
||||
options: InternalOptions
|
||||
sessionStore: SessionStore
|
||||
}): Promise<ResponseInternal> {
|
||||
const { options, sessionStore } = params
|
||||
const { adapter, events, jwt, callbackUrl, logger, session } = options
|
||||
|
||||
const sessionToken = sessionStore?.value
|
||||
if (!sessionToken) {
|
||||
return { redirect: callbackUrl }
|
||||
}
|
||||
|
||||
if (session.strategy === "jwt") {
|
||||
// Dispatch signout event
|
||||
try {
|
||||
const decodedJwt = await jwt.decode({ ...jwt, token: sessionToken })
|
||||
// @ts-expect-error
|
||||
await events.signOut?.({ token: decodedJwt })
|
||||
} catch (error) {
|
||||
// Do nothing if decoding the JWT fails
|
||||
logger.error("SIGNOUT_ERROR", error)
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const session = await (adapter as Adapter).deleteSession(sessionToken)
|
||||
// Dispatch signout event
|
||||
// @ts-expect-error
|
||||
await events.signOut?.({ session })
|
||||
} catch (error) {
|
||||
// If error, log it but continue
|
||||
logger.error("SIGNOUT_ERROR", error as Error)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove Session Token
|
||||
const sessionCookies = sessionStore.clean()
|
||||
|
||||
return { redirect: callbackUrl, cookies: sessionCookies }
|
||||
}
|
||||
290
packages/core/src/lib/styles/index.css
Normal file
290
packages/core/src/lib/styles/index.css
Normal file
@@ -0,0 +1,290 @@
|
||||
:root {
|
||||
--border-width: 1px;
|
||||
--border-radius: 0.5rem;
|
||||
--color-error: #c94b4b;
|
||||
--color-info: #157efb;
|
||||
--color-info-text: #fff;
|
||||
}
|
||||
|
||||
.__next-auth-theme-auto,
|
||||
.__next-auth-theme-light {
|
||||
--color-background: #fff;
|
||||
--color-text: #000;
|
||||
--color-primary: #444;
|
||||
--color-control-border: #bbb;
|
||||
--color-button-active-background: #f9f9f9;
|
||||
--color-button-active-border: #aaa;
|
||||
--color-seperator: #ccc;
|
||||
}
|
||||
|
||||
.__next-auth-theme-dark {
|
||||
--color-background: #000;
|
||||
--color-text: #fff;
|
||||
--color-primary: #ccc;
|
||||
--color-control-border: #555;
|
||||
--color-button-active-background: #060606;
|
||||
--color-button-active-border: #666;
|
||||
--color-seperator: #444;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.__next-auth-theme-auto {
|
||||
--color-background: #000;
|
||||
--color-text: #fff;
|
||||
--color-primary: #ccc;
|
||||
--color-control-border: #555;
|
||||
--color-button-active-background: #060606;
|
||||
--color-button-active-border: #666;
|
||||
--color-seperator: #444;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--color-background);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
|
||||
"Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif,
|
||||
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-weight: 400;
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 0 1rem;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
p {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
form {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
label {
|
||||
font-weight: 500;
|
||||
text-align: left;
|
||||
margin-bottom: 0.25rem;
|
||||
display: block;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
input[type] {
|
||||
box-sizing: border-box;
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.5rem 1rem;
|
||||
border: var(--border-width) solid var(--color-control-border);
|
||||
background: var(--color-background);
|
||||
font-size: 1rem;
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: inset 0 0.1rem 0.2rem rgba(0, 0, 0, 0.2);
|
||||
color: var(--color-text);
|
||||
|
||||
&:focus {
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 0 1.5rem 0;
|
||||
padding: 0 1rem;
|
||||
font-size: 1.1rem;
|
||||
line-height: 2rem;
|
||||
}
|
||||
|
||||
a.button {
|
||||
text-decoration: none;
|
||||
line-height: 1rem;
|
||||
|
||||
&:link,
|
||||
&:visited {
|
||||
background-color: var(--color-background);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
button,
|
||||
a.button {
|
||||
margin: 0 0 0.75rem 0;
|
||||
padding: 0.75rem 1rem;
|
||||
color: var(--provider-color, var(--color-primary));
|
||||
background-color: var(--provider-bg, var(--color-background));
|
||||
font-size: 1.1rem;
|
||||
min-height: 62px;
|
||||
border-color: rgba(0, 0, 0, 0.1);
|
||||
border-radius: var(--border-radius);
|
||||
transition: all 0.1s ease-in-out;
|
||||
box-shadow: #000 0px 0px 0px 0px, #000 0px 0px 0px 0px,
|
||||
rgba(0, 0, 0, 0.2) 0px 10px 15px -3px, rgba(0, 0, 0, 0.1) 0px 4px 6px -4px;
|
||||
font-weight: 500;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:has(img) {
|
||||
justify-content: unset;
|
||||
span {
|
||||
flex-grow: 1;
|
||||
}
|
||||
}
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
&:active {
|
||||
box-shadow: 0 0.15rem 0.3rem rgba(0, 0, 0, 0.15),
|
||||
inset 0 0.1rem 0.2rem var(--color-background),
|
||||
inset 0 -0.1rem 0.1rem rgba(0, 0, 0, 0.1);
|
||||
cursor: pointer;
|
||||
}
|
||||
#provider-logo {
|
||||
display: block;
|
||||
}
|
||||
#provider-logo-dark {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
button,
|
||||
a.button {
|
||||
color: var(--provider-dark-color, var(--color-primary));
|
||||
background-color: var(--provider-dark-bg, var(--color-background));
|
||||
border: 1px solid #0d0d0d;
|
||||
box-shadow: #000 0px 0px 0px 0px, #ccc 0px 0px 0px 0px,
|
||||
rgba(255, 255, 255, 0.01) 0px 5px 5px -3px,
|
||||
rgba(255, 255, 255, 0.05) 0px 4px 6px -4px;
|
||||
}
|
||||
#provider-logo {
|
||||
display: none !important;
|
||||
}
|
||||
#provider-logo-dark {
|
||||
display: block !important;
|
||||
}
|
||||
}
|
||||
|
||||
a.site {
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
font-size: 1rem;
|
||||
line-height: 2rem;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.page {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
> div {
|
||||
text-align: center;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.error {
|
||||
a.button {
|
||||
display: inline-block;
|
||||
padding-left: 2rem;
|
||||
padding-right: 2rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.message {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.signin {
|
||||
input[type="text"] {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
hr {
|
||||
display: block;
|
||||
border: 0;
|
||||
border-top: 1px solid var(--color-seperator);
|
||||
margin: 1.5em auto 0 auto;
|
||||
overflow: visible;
|
||||
|
||||
&::before {
|
||||
content: "or";
|
||||
background: var(--color-background);
|
||||
color: #888;
|
||||
padding: 0 0.4rem;
|
||||
position: relative;
|
||||
top: -0.6rem;
|
||||
}
|
||||
}
|
||||
|
||||
.error {
|
||||
background: #f5f5f5;
|
||||
font-weight: 500;
|
||||
border-radius: 0.3rem;
|
||||
background: var(--color-info);
|
||||
|
||||
p {
|
||||
text-align: left;
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.2rem;
|
||||
color: var(--color-info-text);
|
||||
}
|
||||
}
|
||||
|
||||
> div,
|
||||
form {
|
||||
display: block;
|
||||
|
||||
input[type] {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
max-width: 300px;
|
||||
}
|
||||
}
|
||||
.signout {
|
||||
.message {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: inline-block;
|
||||
margin-top: 100px;
|
||||
max-width: 300px;
|
||||
max-height: 150px;
|
||||
}
|
||||
|
||||
.card {
|
||||
max-width: max-content;
|
||||
border: 1px solid var(--color-control-border);
|
||||
border-radius: 5px;
|
||||
padding: 20px 50px;
|
||||
margin: 50px auto;
|
||||
|
||||
.header {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.section-header {
|
||||
color: var(--brand-color, var(--color-text));
|
||||
}
|
||||
2
packages/core/src/lib/styles/index.ts
Normal file
2
packages/core/src/lib/styles/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export default `:root{--border-width:1px;--border-radius:0.5rem;--color-error:#c94b4b;--color-info:#157efb;--color-info-text:#fff}.__next-auth-theme-auto,.__next-auth-theme-light{--color-background:#fff;--color-text:#000;--color-primary:#444;--color-control-border:#bbb;--color-button-active-background:#f9f9f9;--color-button-active-border:#aaa;--color-seperator:#ccc}.__next-auth-theme-dark{--color-background:#000;--color-text:#fff;--color-primary:#ccc;--color-control-border:#555;--color-button-active-background:#060606;--color-button-active-border:#666;--color-seperator:#444}@media (prefers-color-scheme:dark){.__next-auth-theme-auto{--color-background:#000;--color-text:#fff;--color-primary:#ccc;--color-control-border:#555;--color-button-active-background:#060606;--color-button-active-border:#666;--color-seperator:#444}}body{background-color:var(--color-background);font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,Noto Sans,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;margin:0;padding:0}h1{font-weight:400;margin-bottom:1.5rem;padding:0 1rem}h1,p{color:var(--color-text)}form{margin:0;padding:0}label{font-weight:500;margin-bottom:.25rem;text-align:left}input[type],label{color:var(--color-text);display:block}input[type]{background:var(--color-background);border:var(--border-width) solid var(--color-control-border);border-radius:var(--border-radius);box-shadow:inset 0 .1rem .2rem rgba(0,0,0,.2);box-sizing:border-box;font-size:1rem;padding:.5rem 1rem;width:100%}input[type]:focus{box-shadow:none}p{font-size:1.1rem;line-height:2rem;margin:0 0 1.5rem;padding:0 1rem}a.button{line-height:1rem;text-decoration:none}a.button:link,a.button:visited{background-color:var(--color-background);color:var(--color-primary)}a.button,button{align-items:center;background-color:var(--provider-bg,var(--color-background));border-color:rgba(0,0,0,.1);border-radius:var(--border-radius);box-shadow:0 0 0 0 #000,0 0 0 0 #000,0 10px 15px -3px rgba(0,0,0,.2),0 4px 6px -4px rgba(0,0,0,.1);color:var(--provider-color,var(--color-primary));display:flex;font-size:1.1rem;font-weight:500;justify-content:center;margin:0 0 .75rem;min-height:62px;padding:.75rem 1rem;position:relative;transition:all .1s ease-in-out}a.button:has(img),button:has(img){justify-content:unset}a.button:has(img) span,button:has(img) span{flex-grow:1}a.button:hover,button:hover{cursor:pointer}a.button:active,button:active{box-shadow:0 .15rem .3rem rgba(0,0,0,.15),inset 0 .1rem .2rem var(--color-background),inset 0 -.1rem .1rem rgba(0,0,0,.1);cursor:pointer}a.button #provider-logo,button #provider-logo{display:block}a.button #provider-logo-dark,button #provider-logo-dark{display:none}@media (prefers-color-scheme:dark){a.button,button{background-color:var(--provider-dark-bg,var(--color-background));border:1px solid #0d0d0d;box-shadow:0 0 0 0 #000,0 0 0 0 #ccc,0 5px 5px -3px hsla(0,0%,100%,.01),0 4px 6px -4px hsla(0,0%,100%,.05);color:var(--provider-dark-color,var(--color-primary))}#provider-logo{display:none!important}#provider-logo-dark{display:block!important}}a.site{color:var(--color-primary);font-size:1rem;line-height:2rem;text-decoration:none}a.site:hover{text-decoration:underline}.page{display:grid;height:100%;margin:0;padding:0;place-items:center;position:absolute;width:100%}.page>div{padding:.5rem;text-align:center}.error a.button{display:inline-block;margin-top:.5rem;padding-left:2rem;padding-right:2rem}.error .message{margin-bottom:1.5rem}.signin input[type=text]{display:block;margin-left:auto;margin-right:auto}.signin hr{border:0;border-top:1px solid var(--color-seperator);display:block;margin:1.5em auto 0;overflow:visible}.signin hr:before{background:var(--color-background);color:#888;content:"or";padding:0 .4rem;position:relative;top:-.6rem}.signin .error{background:#f5f5f5;background:var(--color-info);border-radius:.3rem;font-weight:500}.signin .error p{color:var(--color-info-text);font-size:.9rem;line-height:1.2rem;padding:.5rem 1rem;text-align:left}.signin form,.signin>div{display:block}.signin form input[type],.signin>div input[type]{margin-bottom:.5rem}.signin form button,.signin>div button{width:100%}.signin form,.signin>div{max-width:300px}.signout .message{margin-bottom:1.5rem}.logo{display:inline-block;margin-top:100px;max-height:150px;max-width:300px}.card{border:1px solid var(--color-control-border);border-radius:5px;margin:50px auto;max-width:-moz-max-content;max-width:max-content;padding:20px 50px}.card .header{color:var(--color-primary)}.section-header{color:var(--brand-color,var(--color-text))}`
|
||||
// Generated by `pnpm css`
|
||||
578
packages/core/src/lib/types.ts
Normal file
578
packages/core/src/lib/types.ts
Normal file
@@ -0,0 +1,578 @@
|
||||
import type { CookieSerializeOptions } from "cookie"
|
||||
import type { Adapter, AdapterUser } from "../adapters"
|
||||
import type {
|
||||
CredentialInput,
|
||||
CredentialsConfig,
|
||||
EmailConfig,
|
||||
OAuthConfigInternal,
|
||||
Provider,
|
||||
ProviderType,
|
||||
} from "../providers"
|
||||
import type {
|
||||
OAuth2TokenEndpointResponse,
|
||||
OpenIDTokenEndpointResponse,
|
||||
} from "oauth4webapi"
|
||||
import type { JWT, JWTOptions } from "../jwt"
|
||||
import type { Cookie } from "./cookie"
|
||||
import type { LoggerInstance } from "./utils/logger"
|
||||
|
||||
/** @internal */
|
||||
export type Awaitable<T> = T | PromiseLike<T>
|
||||
|
||||
export type { LoggerInstance }
|
||||
|
||||
/**
|
||||
* Configure your NextAuth instance
|
||||
*
|
||||
* [Documentation](https://next-auth.js.org/configuration/options#options)
|
||||
*/
|
||||
export interface AuthOptions {
|
||||
/**
|
||||
* An array of authentication providers for signing in
|
||||
* (e.g. Google, Facebook, Twitter, GitHub, Email, etc) in any order.
|
||||
* This can be one of the built-in providers or an object with a custom provider.
|
||||
* * **Default value**: `[]`
|
||||
* * **Required**: *Yes*
|
||||
*
|
||||
* [Documentation](https://next-auth.js.org/configuration/options#providers) | [Providers documentation](https://next-auth.js.org/configuration/providers)
|
||||
*/
|
||||
providers: Provider[]
|
||||
/**
|
||||
* A random string used to hash tokens, sign cookies and generate cryptographic keys.
|
||||
* If not specified, it falls back to `AUTH_SECRET` or `NEXTAUTH_SECRET` from environment variables.
|
||||
* To generate a random string, you can use the following command:
|
||||
*
|
||||
* On Unix systems: `openssl rand -hex 32`
|
||||
* Or go to https://generate-secret.vercel.app/32
|
||||
*
|
||||
* @default process.env.AUTH_SECRET ?? process.env.NEXTAUTH_SECRET
|
||||
*
|
||||
* [Documentation](https://next-auth.js.org/configuration/options#secret)
|
||||
*/
|
||||
secret?: string
|
||||
/**
|
||||
* Configure your session like if you want to use JWT or a database,
|
||||
* how long until an idle session expires, or to throttle write operations in case you are using a database.
|
||||
* * **Default value**: See the documentation page
|
||||
* * **Required**: No
|
||||
*
|
||||
* [Documentation](https://next-auth.js.org/configuration/options#session)
|
||||
*/
|
||||
session?: Partial<SessionOptions>
|
||||
/**
|
||||
* JSON Web Tokens are enabled by default if you have not specified an adapter.
|
||||
* JSON Web Tokens are encrypted (JWE) by default. We recommend you keep this behaviour.
|
||||
* * **Default value**: See the documentation page
|
||||
* * **Required**: *No*
|
||||
*
|
||||
* [Documentation](https://next-auth.js.org/configuration/options#jwt)
|
||||
*/
|
||||
jwt?: Partial<JWTOptions>
|
||||
/**
|
||||
* Specify URLs to be used if you want to create custom sign in, sign out and error pages.
|
||||
* Pages specified will override the corresponding built-in page.
|
||||
* * **Default value**: `{}`
|
||||
* * **Required**: *No*
|
||||
* @example
|
||||
*
|
||||
* ```js
|
||||
* pages: {
|
||||
* signIn: '/auth/signin',
|
||||
* signOut: '/auth/signout',
|
||||
* error: '/auth/error',
|
||||
* verifyRequest: '/auth/verify-request',
|
||||
* newUser: '/auth/new-user'
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* [Documentation](https://next-auth.js.org/configuration/options#pages) | [Pages documentation](https://next-auth.js.org/configuration/pages)
|
||||
*/
|
||||
pages?: Partial<PagesOptions>
|
||||
/**
|
||||
* Callbacks are asynchronous functions you can use to control what happens when an action is performed.
|
||||
* Callbacks are *extremely powerful*, especially in scenarios involving JSON Web Tokens
|
||||
* as they **allow you to implement access controls without a database** and to **integrate with external databases or APIs**.
|
||||
* * **Default value**: See the Callbacks documentation
|
||||
* * **Required**: *No*
|
||||
*
|
||||
* [Documentation](https://next-auth.js.org/configuration/options#callbacks) | [Callbacks documentation](https://next-auth.js.org/configuration/callbacks)
|
||||
*/
|
||||
callbacks?: Partial<CallbacksOptions>
|
||||
/**
|
||||
* Events are asynchronous functions that do not return a response, they are useful for audit logging.
|
||||
* You can specify a handler for any of these events below - e.g. for debugging or to create an audit log.
|
||||
* The content of the message object varies depending on the flow
|
||||
* (e.g. OAuth or Email authentication flow, JWT or database sessions, etc),
|
||||
* but typically contains a user object and/or contents of the JSON Web Token
|
||||
* and other information relevant to the event.
|
||||
* * **Default value**: `{}`
|
||||
* * **Required**: *No*
|
||||
*
|
||||
* [Documentation](https://next-auth.js.org/configuration/options#events) | [Events documentation](https://next-auth.js.org/configuration/events)
|
||||
*/
|
||||
events?: Partial<EventCallbacks>
|
||||
/**
|
||||
* You can use the adapter option to pass in your database adapter.
|
||||
*
|
||||
* * **Required**: *No*
|
||||
*
|
||||
* [Documentation](https://next-auth.js.org/configuration/options#adapter) |
|
||||
* [Adapters Overview](https://next-auth.js.org/adapters/overview)
|
||||
*/
|
||||
adapter?: Adapter
|
||||
/**
|
||||
* Set debug to true to enable debug messages for authentication and database operations.
|
||||
* * **Default value**: `false`
|
||||
* * **Required**: *No*
|
||||
*
|
||||
* - ⚠ If you added a custom `logger`, this setting is ignored.
|
||||
*
|
||||
* [Documentation](https://next-auth.js.org/configuration/options#debug) | [Logger documentation](https://next-auth.js.org/configuration/options#logger)
|
||||
*/
|
||||
debug?: boolean
|
||||
/**
|
||||
* Override any of the logger levels (`undefined` levels will use the built-in logger),
|
||||
* and intercept logs in NextAuth. You can use this option to send NextAuth logs to a third-party logging service.
|
||||
* * **Default value**: `console`
|
||||
* * **Required**: *No*
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* ```js
|
||||
* // /pages/api/auth/[...nextauth].js
|
||||
* import log from "logging-service"
|
||||
* export default NextAuth({
|
||||
* logger: {
|
||||
* error(code, ...message) {
|
||||
* log.error(code, message)
|
||||
* },
|
||||
* warn(code, ...message) {
|
||||
* log.warn(code, message)
|
||||
* },
|
||||
* debug(code, ...message) {
|
||||
* log.debug(code, message)
|
||||
* }
|
||||
* }
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
* - ⚠ When set, the `debug` option is ignored
|
||||
*
|
||||
* [Documentation](https://next-auth.js.org/configuration/options#logger) |
|
||||
* [Debug documentation](https://next-auth.js.org/configuration/options#debug)
|
||||
*/
|
||||
logger?: Partial<LoggerInstance>
|
||||
/**
|
||||
* Changes the theme of pages.
|
||||
* Set to `"light"` if you want to force pages to always be light.
|
||||
* Set to `"dark"` if you want to force pages to always be dark.
|
||||
* Set to `"auto"`, (or leave this option out)if you want the pages to follow the preferred system theme.
|
||||
* * **Default value**: `"auto"`
|
||||
* * **Required**: *No*
|
||||
*
|
||||
* [Documentation](https://next-auth.js.org/configuration/options#theme) | [Pages documentation]("https://next-auth.js.org/configuration/pages")
|
||||
*/
|
||||
theme?: Theme
|
||||
/**
|
||||
* When set to `true` then all cookies set by NextAuth.js will only be accessible from HTTPS URLs.
|
||||
* This option defaults to `false` on URLs that start with `http://` (e.g. http://localhost:3000) for developer convenience.
|
||||
* You can manually set this option to `false` to disable this security feature and allow cookies
|
||||
* to be accessible from non-secured URLs (this is not recommended).
|
||||
* * **Default value**: `true` for HTTPS and `false` for HTTP sites
|
||||
* * **Required**: No
|
||||
*
|
||||
* [Documentation](https://next-auth.js.org/configuration/options#usesecurecookies)
|
||||
*
|
||||
* - ⚠ **This is an advanced option.** Advanced options are passed the same way as basic options,
|
||||
* but **may have complex implications** or side effects.
|
||||
* You should **try to avoid using advanced options** unless you are very comfortable using them.
|
||||
*/
|
||||
useSecureCookies?: boolean
|
||||
/**
|
||||
* You can override the default cookie names and options for any of the cookies used by NextAuth.js.
|
||||
* You can specify one or more cookies with custom properties,
|
||||
* but if you specify custom options for a cookie you must provide all the options for that cookie.
|
||||
* If you use this feature, you will likely want to create conditional behavior
|
||||
* to support setting different cookies policies in development and production builds,
|
||||
* as you will be opting out of the built-in dynamic policy.
|
||||
* * **Default value**: `{}`
|
||||
* * **Required**: No
|
||||
*
|
||||
* - ⚠ **This is an advanced option.** Advanced options are passed the same way as basic options,
|
||||
* but **may have complex implications** or side effects.
|
||||
* You should **try to avoid using advanced options** unless you are very comfortable using them.
|
||||
*
|
||||
* [Documentation](https://next-auth.js.org/configuration/options#cookies) | [Usage example](https://next-auth.js.org/configuration/options#example)
|
||||
*/
|
||||
cookies?: Partial<CookiesOptions>
|
||||
/**
|
||||
* If set to `true`, NextAuth.js will use either the `x-forwarded-host` or `host` headers,
|
||||
* instead of `NEXTAUTH_URL`
|
||||
* Make sure that reading `x-forwarded-host` on your hosting platform can be trusted.
|
||||
* - ⚠ **This is an advanced option.** Advanced options are passed the same way as basic options,
|
||||
* but **may have complex implications** or side effects.
|
||||
* You should **try to avoid using advanced options** unless you are very comfortable using them.
|
||||
* @default Boolean(process.env.NEXTAUTH_URL ?? process.env.AUTH_TRUST_HOST ?? process.env.VERCEL)
|
||||
*/
|
||||
trustHost?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the theme of the built-in pages.
|
||||
*
|
||||
* [Documentation](https://next-auth.js.org/configuration/options#theme) |
|
||||
* [Pages](https://next-auth.js.org/configuration/pages)
|
||||
*/
|
||||
export interface Theme {
|
||||
colorScheme?: "auto" | "dark" | "light"
|
||||
logo?: string
|
||||
brandColor?: string
|
||||
buttonText?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Different tokens returned by OAuth Providers.
|
||||
* Some of them are available with different casing,
|
||||
* but they refer to the same value.
|
||||
*/
|
||||
export type TokenSet = Partial<
|
||||
OAuth2TokenEndpointResponse | OpenIDTokenEndpointResponse
|
||||
>
|
||||
|
||||
/**
|
||||
* Usually contains information about the provider being used
|
||||
* and also extends `TokenSet`, which is different tokens returned by OAuth Providers.
|
||||
*/
|
||||
export interface Account extends Partial<OpenIDTokenEndpointResponse> {
|
||||
/**
|
||||
* This value depends on the type of the provider being used to create the account.
|
||||
* - oauth: The OAuth account's id, returned from the `profile()` callback.
|
||||
* - email: The user's email address.
|
||||
* - credentials: `id` returned from the `authorize()` callback
|
||||
*/
|
||||
providerAccountId: string
|
||||
/** id of the user this account belongs to. */
|
||||
userId?: string
|
||||
/** id of the provider used for this account */
|
||||
provider: string
|
||||
/** Provider's type for this account */
|
||||
type: ProviderType
|
||||
}
|
||||
|
||||
/** The OAuth profile returned from your provider */
|
||||
export interface Profile {
|
||||
sub?: string
|
||||
name?: string
|
||||
email?: string
|
||||
image?: string
|
||||
}
|
||||
|
||||
/** [Documentation](https://next-auth.js.org/configuration/callbacks) */
|
||||
export interface CallbacksOptions<P = Profile, A = Account> {
|
||||
/**
|
||||
* Use this callback to control if a user is allowed to sign in.
|
||||
* Returning true will continue the sign-in flow.
|
||||
* Throwing an error or returning a string will stop the flow, and redirect the user.
|
||||
*
|
||||
* [Documentation](https://next-auth.js.org/configuration/callbacks#sign-in-callback)
|
||||
*/
|
||||
signIn: (params: {
|
||||
user: User | AdapterUser
|
||||
account: A | null
|
||||
/**
|
||||
* If OAuth provider is used, it contains the full
|
||||
* OAuth profile returned by your provider.
|
||||
*/
|
||||
profile?: P
|
||||
/**
|
||||
* If Email provider is used, on the first call, it contains a
|
||||
* `verificationRequest: true` property to indicate it is being triggered in the verification request flow.
|
||||
* When the callback is invoked after a user has clicked on a sign in link,
|
||||
* this property will not be present. You can check for the `verificationRequest` property
|
||||
* to avoid sending emails to addresses or domains on a blocklist or to only explicitly generate them
|
||||
* for email address in an allow list.
|
||||
*/
|
||||
email?: {
|
||||
verificationRequest?: boolean
|
||||
}
|
||||
/** If Credentials provider is used, it contains the user credentials */
|
||||
credentials?: Record<string, CredentialInput>
|
||||
}) => Awaitable<string | boolean>
|
||||
/**
|
||||
* This callback is called anytime the user is redirected to a callback URL (e.g. on signin or signout).
|
||||
* By default only URLs on the same URL as the site are allowed,
|
||||
* you can use this callback to customise that behaviour.
|
||||
*
|
||||
* [Documentation](https://next-auth.js.org/configuration/callbacks#redirect-callback)
|
||||
*/
|
||||
redirect: (params: {
|
||||
/** URL provided as callback URL by the client */
|
||||
url: string
|
||||
/** Default base URL of site (can be used as fallback) */
|
||||
baseUrl: string
|
||||
}) => Awaitable<string>
|
||||
/**
|
||||
* This callback is called whenever a session is checked.
|
||||
* (Eg.: invoking the `/api/session` endpoint, using `useSession` or `getSession`)
|
||||
*
|
||||
* ⚠ By default, only a subset (email, name, image)
|
||||
* of the token is returned for increased security.
|
||||
*
|
||||
* If you want to make something available you added to the token through the `jwt` callback,
|
||||
* you have to explicitly forward it here to make it available to the client.
|
||||
*
|
||||
* [Documentation](https://next-auth.js.org/configuration/callbacks#session-callback) |
|
||||
* [`jwt` callback](https://next-auth.js.org/configuration/callbacks#jwt-callback) |
|
||||
* [`useSession`](https://next-auth.js.org/getting-started/client#usesession) |
|
||||
* [`getSession`](https://next-auth.js.org/getting-started/client#getsession) |
|
||||
*
|
||||
*/
|
||||
session: (params: {
|
||||
session: Session
|
||||
user: User | AdapterUser
|
||||
token: JWT
|
||||
}) => Awaitable<Session>
|
||||
/**
|
||||
* This callback is called whenever a JSON Web Token is created (i.e. at sign in)
|
||||
* or updated (i.e whenever a session is accessed in the client).
|
||||
* Its content is forwarded to the `session` callback,
|
||||
* where you can control what should be returned to the client.
|
||||
* Anything else will be kept from your front-end.
|
||||
*
|
||||
* ⚠ By default the JWT is signed, but not encrypted.
|
||||
*
|
||||
* [Documentation](https://next-auth.js.org/configuration/callbacks#jwt-callback) |
|
||||
* [`session` callback](https://next-auth.js.org/configuration/callbacks#session-callback)
|
||||
*/
|
||||
jwt: (params: {
|
||||
token: JWT
|
||||
user?: User | AdapterUser
|
||||
account?: A | null
|
||||
profile?: P
|
||||
isNewUser?: boolean
|
||||
}) => Awaitable<JWT>
|
||||
}
|
||||
|
||||
/** [Documentation](https://next-auth.js.org/configuration/options#cookies) */
|
||||
export interface CookieOption {
|
||||
name: string
|
||||
options: CookieSerializeOptions
|
||||
}
|
||||
|
||||
/** [Documentation](https://next-auth.js.org/configuration/options#cookies) */
|
||||
export interface CookiesOptions {
|
||||
sessionToken: CookieOption
|
||||
callbackUrl: CookieOption
|
||||
csrfToken: CookieOption
|
||||
pkceCodeVerifier: CookieOption
|
||||
state: CookieOption
|
||||
nonce: CookieOption
|
||||
}
|
||||
|
||||
/**
|
||||
* The various event callbacks you can register for from next-auth
|
||||
*
|
||||
* [Documentation](https://next-auth.js.org/configuration/events)
|
||||
*/
|
||||
export interface EventCallbacks {
|
||||
/**
|
||||
* If using a `credentials` type auth, the user is the raw response from your
|
||||
* credential provider.
|
||||
* For other providers, you'll get the User object from your adapter, the account,
|
||||
* and an indicator if the user was new to your Adapter.
|
||||
*/
|
||||
signIn: (message: {
|
||||
user: User
|
||||
account: Account | null
|
||||
profile?: Profile
|
||||
isNewUser?: boolean
|
||||
}) => Awaitable<void>
|
||||
/**
|
||||
* The message object will contain one of these depending on
|
||||
* if you use JWT or database persisted sessions:
|
||||
* - `token`: The JWT token for this session.
|
||||
* - `session`: The session object from your adapter that is being ended.
|
||||
*/
|
||||
signOut: (message: { session: Session; token: JWT }) => Awaitable<void>
|
||||
createUser: (message: { user: User }) => Awaitable<void>
|
||||
updateUser: (message: { user: User }) => Awaitable<void>
|
||||
linkAccount: (message: {
|
||||
user: User | AdapterUser
|
||||
account: Account
|
||||
profile: User | AdapterUser
|
||||
}) => Awaitable<void>
|
||||
/**
|
||||
* The message object will contain one of these depending on
|
||||
* if you use JWT or database persisted sessions:
|
||||
* - `token`: The JWT token for this session.
|
||||
* - `session`: The session object from your adapter.
|
||||
*/
|
||||
session: (message: { session: Session; token: JWT }) => Awaitable<void>
|
||||
}
|
||||
|
||||
export type EventType = keyof EventCallbacks
|
||||
|
||||
/** [Documentation](https://next-auth.js.org/configuration/pages) */
|
||||
export interface PagesOptions {
|
||||
signIn: string
|
||||
signOut: string
|
||||
/** Error code passed in query string as ?error= */
|
||||
error: string
|
||||
verifyRequest: string
|
||||
/** If set, new users will be directed here on first sign in */
|
||||
newUser: string
|
||||
}
|
||||
|
||||
type ISODateString = string
|
||||
|
||||
export interface DefaultSession {
|
||||
user?: {
|
||||
name?: string | null
|
||||
email?: string | null
|
||||
image?: string | null
|
||||
}
|
||||
expires: ISODateString
|
||||
}
|
||||
|
||||
/**
|
||||
* Returned by `useSession`, `getSession`, returned by the `session` callback
|
||||
* and also the shape received as a prop on the `SessionProvider` React Context
|
||||
*
|
||||
* [`useSession`](https://next-auth.js.org/getting-started/client#usesession) |
|
||||
* [`getSession`](https://next-auth.js.org/getting-started/client#getsession) |
|
||||
* [`SessionProvider`](https://next-auth.js.org/getting-started/client#sessionprovider) |
|
||||
* [`session` callback](https://next-auth.js.org/configuration/callbacks#jwt-callback)
|
||||
*/
|
||||
export interface Session extends DefaultSession {}
|
||||
|
||||
export type SessionStrategy = "jwt" | "database"
|
||||
|
||||
/** [Documentation](https://next-auth.js.org/configuration/options#session) */
|
||||
export interface SessionOptions {
|
||||
/**
|
||||
* Choose how you want to save the user session.
|
||||
* The default is `"jwt"`, an encrypted JWT (JWE) in the session cookie.
|
||||
*
|
||||
* If you use an `adapter` however, we default it to `"database"` instead.
|
||||
* You can still force a JWT session by explicitly defining `"jwt"`.
|
||||
*
|
||||
* When using `"database"`, the session cookie will only contain a `sessionToken` value,
|
||||
* which is used to look up the session in the database.
|
||||
*
|
||||
* [Documentation](https://next-auth.js.org/configuration/options#session) | [Adapter](https://next-auth.js.org/configuration/options#adapter) | [About JSON Web Tokens](https://next-auth.js.org/faq#json-web-tokens)
|
||||
*/
|
||||
strategy: SessionStrategy
|
||||
/**
|
||||
* Relative time from now in seconds when to expire the session
|
||||
* @default 2592000 // 30 days
|
||||
*/
|
||||
maxAge: number
|
||||
/**
|
||||
* How often the session should be updated in seconds.
|
||||
* If set to `0`, session is updated every time.
|
||||
* @default 86400 // 1 day
|
||||
*/
|
||||
updateAge: number
|
||||
/**
|
||||
* Generate a custom session token for database-based sessions.
|
||||
* By default, a random UUID or string is generated depending on the Node.js version.
|
||||
* However, you can specify your own custom string (such as CUID) to be used.
|
||||
* @default `randomUUID` or `randomBytes.toHex` depending on the Node.js version
|
||||
*/
|
||||
generateSessionToken: () => string
|
||||
}
|
||||
|
||||
export interface DefaultUser {
|
||||
id: string
|
||||
name?: string | null
|
||||
email?: string | null
|
||||
image?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* The shape of the returned object in the OAuth providers' `profile` callback,
|
||||
* available in the `jwt` and `session` callbacks,
|
||||
* or the second parameter of the `session` callback, when using a database.
|
||||
*
|
||||
* [`signIn` callback](https://next-auth.js.org/configuration/callbacks#sign-in-callback) |
|
||||
* [`session` callback](https://next-auth.js.org/configuration/callbacks#jwt-callback) |
|
||||
* [`jwt` callback](https://next-auth.js.org/configuration/callbacks#jwt-callback) |
|
||||
* [`profile` OAuth provider callback](https://next-auth.js.org/configuration/providers#using-a-custom-provider)
|
||||
*/
|
||||
export interface User extends DefaultUser {}
|
||||
|
||||
// Below are types that are only supposed be used by next-auth internally
|
||||
|
||||
/** @internal */
|
||||
export type InternalProvider<T = ProviderType> = (T extends "oauth"
|
||||
? OAuthConfigInternal<any>
|
||||
: T extends "email"
|
||||
? EmailConfig
|
||||
: T extends "credentials"
|
||||
? CredentialsConfig
|
||||
: never) & {
|
||||
signinUrl: string
|
||||
callbackUrl: string
|
||||
}
|
||||
|
||||
export type AuthAction =
|
||||
| "providers"
|
||||
| "session"
|
||||
| "csrf"
|
||||
| "signin"
|
||||
| "signout"
|
||||
| "callback"
|
||||
| "verify-request"
|
||||
| "error"
|
||||
| "_log"
|
||||
|
||||
/** @internal */
|
||||
export interface RequestInternal {
|
||||
url: URL
|
||||
method?: string
|
||||
cookies?: Partial<Record<string, string>>
|
||||
headers?: Record<string, any>
|
||||
query?: Record<string, any>
|
||||
body?: Record<string, any>
|
||||
action: AuthAction
|
||||
providerId?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export interface ResponseInternal<
|
||||
Body extends string | Record<string, any> | any[] = any
|
||||
> {
|
||||
status?: number
|
||||
headers?: Headers | HeadersInit
|
||||
body?: Body
|
||||
redirect?: URL | string
|
||||
cookies?: Cookie[]
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export interface InternalOptions<
|
||||
TProviderType = ProviderType,
|
||||
WithVerificationToken = TProviderType extends "email" ? true : false
|
||||
> {
|
||||
providers: InternalProvider[]
|
||||
url: URL
|
||||
action: AuthAction
|
||||
provider: InternalProvider<TProviderType>
|
||||
csrfToken?: string
|
||||
csrfTokenVerified?: boolean
|
||||
secret: string
|
||||
theme: Theme
|
||||
debug: boolean
|
||||
logger: LoggerInstance
|
||||
session: Required<SessionOptions>
|
||||
pages: Partial<PagesOptions>
|
||||
jwt: JWTOptions
|
||||
events: Partial<EventCallbacks>
|
||||
adapter: WithVerificationToken extends true
|
||||
? Adapter<WithVerificationToken>
|
||||
: Adapter<WithVerificationToken> | undefined
|
||||
callbacks: CallbacksOptions
|
||||
cookies: CookiesOptions
|
||||
callbackUrl: string
|
||||
}
|
||||
8
packages/core/src/lib/utils/date.ts
Normal file
8
packages/core/src/lib/utils/date.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Takes a number in seconds and returns the date in the future.
|
||||
* Optionally takes a second date parameter. In that case
|
||||
* the date in the future will be calculated from that date instead of now.
|
||||
*/
|
||||
export function fromDate(time: number, date = Date.now()) {
|
||||
return new Date(date + time * 1000)
|
||||
}
|
||||
112
packages/core/src/lib/utils/logger.ts
Normal file
112
packages/core/src/lib/utils/logger.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { UnknownError } from "../errors"
|
||||
|
||||
/** Makes sure that error is always serializable */
|
||||
function formatError(o: unknown): unknown {
|
||||
if (o instanceof Error && !(o instanceof UnknownError)) {
|
||||
return { message: o.message, stack: o.stack, name: o.name }
|
||||
}
|
||||
if (hasErrorProperty(o)) {
|
||||
o.error = formatError(o.error) as Error
|
||||
o.message = o.message ?? o.error.message
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
function hasErrorProperty(
|
||||
x: unknown
|
||||
): x is { error: Error; [key: string]: unknown } {
|
||||
return !!(x as any)?.error
|
||||
}
|
||||
|
||||
export type WarningCode = "NEXTAUTH_URL" | "DEBUG_ENABLED"
|
||||
|
||||
/**
|
||||
* Override any of the methods, and the rest will use the default logger.
|
||||
*
|
||||
* [Documentation](https://next-auth.js.org/configuration/options#logger)
|
||||
*/
|
||||
export interface LoggerInstance extends Record<string, Function> {
|
||||
warn: (code: WarningCode) => void
|
||||
error: (
|
||||
code: string,
|
||||
/**
|
||||
* Either an instance of (JSON serializable) Error
|
||||
* or an object that contains some debug information.
|
||||
* (Error is still available through `metadata.error`)
|
||||
*/
|
||||
metadata: Error | { error: Error; [key: string]: unknown }
|
||||
) => void
|
||||
debug: (code: string, metadata: unknown) => void
|
||||
}
|
||||
|
||||
const _logger: LoggerInstance = {
|
||||
error(code, metadata) {
|
||||
metadata = formatError(metadata) as Error
|
||||
console.error(
|
||||
`[next-auth][error][${code}]`,
|
||||
`\nhttps://next-auth.js.org/errors#${code.toLowerCase()}`,
|
||||
metadata.message,
|
||||
metadata
|
||||
)
|
||||
},
|
||||
warn(code) {
|
||||
console.warn(
|
||||
`[next-auth][warn][${code}]`,
|
||||
`\nhttps://next-auth.js.org/warnings#${code.toLowerCase()}`
|
||||
)
|
||||
},
|
||||
debug(code, metadata) {
|
||||
console.log(`[next-auth][debug][${code}]`, metadata)
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Override the built-in logger with user's implementation.
|
||||
* Any `undefined` level will use the default logger.
|
||||
*/
|
||||
export function setLogger(
|
||||
newLogger: Partial<LoggerInstance> = {},
|
||||
debug?: boolean
|
||||
) {
|
||||
// Turn off debug logging if `debug` isn't set to `true`
|
||||
if (!debug) _logger.debug = () => {}
|
||||
|
||||
if (newLogger.error) _logger.error = newLogger.error
|
||||
if (newLogger.warn) _logger.warn = newLogger.warn
|
||||
if (newLogger.debug) _logger.debug = newLogger.debug
|
||||
}
|
||||
|
||||
export default _logger
|
||||
|
||||
/** Serializes client-side log messages and sends them to the server */
|
||||
export function proxyLogger(
|
||||
logger: LoggerInstance = _logger,
|
||||
basePath?: string
|
||||
): LoggerInstance {
|
||||
try {
|
||||
if (typeof window === "undefined") {
|
||||
return logger
|
||||
}
|
||||
|
||||
const clientLogger: Record<string, unknown> = {}
|
||||
for (const level in logger) {
|
||||
clientLogger[level] = (code: string, metadata: Error) => {
|
||||
_logger[level](code, metadata) // Logs to console
|
||||
|
||||
if (level === "error") {
|
||||
metadata = formatError(metadata) as Error
|
||||
}
|
||||
;(metadata as any).client = true
|
||||
const url = `${basePath}/_log`
|
||||
const body = new URLSearchParams({ level, code, ...(metadata as any) })
|
||||
if (navigator.sendBeacon) {
|
||||
return navigator.sendBeacon(url, body)
|
||||
}
|
||||
return fetch(url, { method: "POST", body, keepalive: true })
|
||||
}
|
||||
}
|
||||
return clientLogger as unknown as LoggerInstance
|
||||
} catch {
|
||||
return _logger
|
||||
}
|
||||
}
|
||||
25
packages/core/src/lib/utils/merge.ts
Normal file
25
packages/core/src/lib/utils/merge.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
// Source: https://stackoverflow.com/a/34749873/5364135
|
||||
|
||||
/** Simple object check */
|
||||
function isObject(item: any): boolean {
|
||||
return item && typeof item === "object" && !Array.isArray(item)
|
||||
}
|
||||
|
||||
/** Deep merge two objects */
|
||||
export function merge(target: any, ...sources: any[]): any {
|
||||
if (!sources.length) return target
|
||||
const source = sources.shift()
|
||||
|
||||
if (isObject(target) && isObject(source)) {
|
||||
for (const key in source) {
|
||||
if (isObject(source[key])) {
|
||||
if (!target[key]) Object.assign(target, { [key]: {} })
|
||||
merge(target[key], source[key])
|
||||
} else {
|
||||
Object.assign(target, { [key]: source[key] })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return merge(target, ...sources)
|
||||
}
|
||||
36
packages/core/src/lib/utils/parse-url.ts
Normal file
36
packages/core/src/lib/utils/parse-url.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
interface InternalUrl {
|
||||
/** @default "http://localhost:3000" */
|
||||
origin: string
|
||||
/** @default "localhost:3000" */
|
||||
host: string
|
||||
/** @default "/api/auth" */
|
||||
path: string
|
||||
/** @default "http://localhost:3000/api/auth" */
|
||||
base: string
|
||||
/** @default "http://localhost:3000/api/auth" */
|
||||
toString: () => string
|
||||
}
|
||||
|
||||
/** Returns an `URL` like object to make requests/redirects from server-side */
|
||||
export default function parseUrl(url?: string | URL): InternalUrl {
|
||||
const defaultUrl = new URL("http://localhost:3000/api/auth")
|
||||
|
||||
if (url && !url.toString().startsWith("http")) {
|
||||
url = `https://${url}`
|
||||
}
|
||||
|
||||
const _url = new URL(url ?? defaultUrl)
|
||||
const path = (_url.pathname === "/" ? defaultUrl.pathname : _url.pathname)
|
||||
// Remove trailing slash
|
||||
.replace(/\/$/, "")
|
||||
|
||||
const base = `${_url.origin}${path}`
|
||||
|
||||
return {
|
||||
origin: _url.origin,
|
||||
host: _url.host,
|
||||
path,
|
||||
base,
|
||||
toString: () => base,
|
||||
}
|
||||
}
|
||||
107
packages/core/src/lib/web.ts
Normal file
107
packages/core/src/lib/web.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { parse as parseCookie, serialize } from "cookie"
|
||||
import type { RequestInternal, ResponseInternal } from ".."
|
||||
import { UnknownAction } from "./errors"
|
||||
import type { AuthAction } from "./types"
|
||||
|
||||
async function getBody(req: Request): Promise<Record<string, any> | undefined> {
|
||||
if (!("body" in req) || !req.body || req.method !== "POST") return
|
||||
|
||||
const contentType = req.headers.get("content-type")
|
||||
if (contentType?.includes("application/json")) {
|
||||
return await req.json()
|
||||
} else if (contentType?.includes("application/x-www-form-urlencoded")) {
|
||||
const params = new URLSearchParams(await req.text())
|
||||
return Object.fromEntries(params)
|
||||
}
|
||||
}
|
||||
// prettier-ignore
|
||||
const actions: AuthAction[] = [ "providers", "session", "csrf", "signin", "signout", "callback", "verify-request", "error", "_log" ]
|
||||
|
||||
export async function toInternalRequest(
|
||||
req: Request
|
||||
): Promise<RequestInternal | Error> {
|
||||
try {
|
||||
// TODO: url.toString() should not include action and providerId
|
||||
// see init.ts
|
||||
const url = new URL(req.url.replace(/\/$/, ""))
|
||||
const { pathname } = url
|
||||
|
||||
const action = actions.find((a) => pathname.includes(a))
|
||||
if (!action) {
|
||||
throw new UnknownAction("Cannot detect action.")
|
||||
}
|
||||
|
||||
const providerIdOrAction = pathname.split("/").pop()
|
||||
let providerId
|
||||
if (
|
||||
providerIdOrAction &&
|
||||
!action.includes(providerIdOrAction) &&
|
||||
["signin", "callback"].includes(action)
|
||||
) {
|
||||
providerId = providerIdOrAction
|
||||
}
|
||||
|
||||
return {
|
||||
url,
|
||||
action,
|
||||
providerId,
|
||||
method: req.method ?? "GET",
|
||||
headers: Object.fromEntries(req.headers),
|
||||
body: req.body ? await getBody(req) : undefined,
|
||||
cookies: parseCookie(req.headers.get("cookie") ?? "") ?? {},
|
||||
error: url.searchParams.get("error") ?? undefined,
|
||||
query: Object.fromEntries(url.searchParams),
|
||||
}
|
||||
} catch (error) {
|
||||
return error
|
||||
}
|
||||
}
|
||||
|
||||
export function toResponse(res: ResponseInternal): Response {
|
||||
const headers = new Headers(res.headers)
|
||||
|
||||
res.cookies?.forEach((cookie) => {
|
||||
const { name, value, options } = cookie
|
||||
const cookieHeader = serialize(name, value, options)
|
||||
if (headers.has("Set-Cookie")) {
|
||||
headers.append("Set-Cookie", cookieHeader)
|
||||
} else {
|
||||
headers.set("Set-Cookie", cookieHeader)
|
||||
}
|
||||
// headers.set("Set-Cookie", cookieHeader) // TODO: Remove. Seems to be a bug with Headers in the runtime
|
||||
})
|
||||
|
||||
const body =
|
||||
headers.get("content-type") === "application/json"
|
||||
? JSON.stringify(res.body)
|
||||
: res.body
|
||||
|
||||
const response = new Response(body, {
|
||||
headers,
|
||||
status: res.redirect ? 302 : res.status ?? 200,
|
||||
})
|
||||
|
||||
if (res.redirect) {
|
||||
response.headers.set("Location", res.redirect.toString())
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
/** Web compatible method to create a hash, using SHA256 */
|
||||
export async function createHash(message) {
|
||||
const data = new TextEncoder().encode(message)
|
||||
const hash = await crypto.subtle.digest("SHA-256", data)
|
||||
return Array.from(new Uint8Array(hash))
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("")
|
||||
.toString()
|
||||
}
|
||||
|
||||
/** Web compatible method to create a random string of a given length */
|
||||
export function randomString(size: number) {
|
||||
const i2hex = (i: number) => ("0" + i.toString(16)).slice(-2)
|
||||
const r = (a: string, i: number): string => a + i2hex(i)
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(size))
|
||||
return Array.from(bytes).reduce(r, "")
|
||||
}
|
||||
178
packages/core/src/providers/42-school.ts
Normal file
178
packages/core/src/providers/42-school.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import type { OAuthConfig, OAuthUserConfig } from "."
|
||||
|
||||
export interface UserData {
|
||||
id: number
|
||||
email: string
|
||||
login: string
|
||||
first_name: string
|
||||
last_name: string
|
||||
usual_full_name: null | string
|
||||
usual_first_name: null | string
|
||||
url: string
|
||||
phone: "hidden" | string | null
|
||||
displayname: string
|
||||
image_url: string | null
|
||||
"staff?": boolean
|
||||
correction_point: number
|
||||
pool_month: string | null
|
||||
pool_year: string | null
|
||||
location: string | null
|
||||
wallet: number
|
||||
anonymize_date: string
|
||||
created_at: string
|
||||
updated_at: string | null
|
||||
alumni: boolean
|
||||
"is_launched?": boolean
|
||||
}
|
||||
|
||||
export interface CursusUser {
|
||||
grade: string | null
|
||||
level: number
|
||||
skills: Array<{ id: number; name: string; level: number }>
|
||||
blackholed_at: string | null
|
||||
id: number
|
||||
begin_at: string | null
|
||||
end_at: string | null
|
||||
cursus_id: number
|
||||
has_coalition: boolean
|
||||
created_at: string
|
||||
updated_at: string | null
|
||||
user: UserData
|
||||
cursus: { id: number; created_at: string; name: string; slug: string }
|
||||
}
|
||||
|
||||
export interface ProjectUser {
|
||||
id: number
|
||||
occurrence: number
|
||||
final_mark: number | null
|
||||
status: "in_progress" | "finished"
|
||||
"validated?": boolean | null
|
||||
current_team_id: number
|
||||
project: {
|
||||
id: number
|
||||
name: string
|
||||
slug: string
|
||||
parent_id: number | null
|
||||
}
|
||||
cursus_ids: number[]
|
||||
marked_at: string | null
|
||||
marked: boolean
|
||||
retriable_at: string | null
|
||||
created_at: string
|
||||
updated_at: string | null
|
||||
}
|
||||
|
||||
export interface Achievement {
|
||||
id: number
|
||||
name: string
|
||||
description: string
|
||||
tier: "none" | "easy" | "medium" | "hard" | "challenge"
|
||||
kind: "scolarity" | "project" | "pedagogy" | "scolarity"
|
||||
visible: boolean
|
||||
image: string | null
|
||||
nbr_of_success: number | null
|
||||
users_url: string
|
||||
}
|
||||
|
||||
export interface LanguagesUser {
|
||||
id: number
|
||||
language_id: number
|
||||
user_id: number
|
||||
position: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface TitlesUser {
|
||||
id: number
|
||||
user_id: number
|
||||
title_id: number
|
||||
selected: boolean
|
||||
created_at: string
|
||||
updated_at: string | null
|
||||
}
|
||||
|
||||
export interface ExpertisesUser {
|
||||
id: number
|
||||
expertise_id: number
|
||||
interested: boolean
|
||||
value: number
|
||||
contact_me: boolean
|
||||
created_at: string
|
||||
user_id: number
|
||||
}
|
||||
|
||||
export interface Campus {
|
||||
id: number
|
||||
name: string
|
||||
time_zone: string
|
||||
language: {
|
||||
id: number
|
||||
name: string
|
||||
identifier: string
|
||||
created_at: string
|
||||
updated_at: string | null
|
||||
}
|
||||
users_count: number
|
||||
vogsphere_id: number
|
||||
country: string
|
||||
address: string
|
||||
zip: string
|
||||
city: string
|
||||
website: string
|
||||
facebook: string
|
||||
twitter: string
|
||||
active: boolean
|
||||
email_extension: string
|
||||
default_hidden_phone: boolean
|
||||
}
|
||||
|
||||
export interface CampusUser {
|
||||
id: number
|
||||
user_id: number
|
||||
campus_id: number
|
||||
is_primary: boolean
|
||||
created_at: string
|
||||
updated_at: string | null
|
||||
}
|
||||
export interface FortyTwoProfile extends UserData, Record<string, any> {
|
||||
groups: Array<{ id: string; name: string }>
|
||||
cursus_users: CursusUser[]
|
||||
projects_users: ProjectUser[]
|
||||
languages_users: LanguagesUser[]
|
||||
achievements: Achievement[]
|
||||
titles: Array<{ id: string; name: string }>
|
||||
titles_users: TitlesUser[]
|
||||
partnerships: any[]
|
||||
patroned: any[]
|
||||
patroning: any[]
|
||||
expertises_users: ExpertisesUser[]
|
||||
roles: Array<{ id: string; name: string }>
|
||||
campus: Campus[]
|
||||
campus_users: CampusUser[]
|
||||
user: any | null
|
||||
}
|
||||
|
||||
export default function FortyTwo<P extends FortyTwoProfile>(
|
||||
options: OAuthUserConfig<P>
|
||||
): OAuthConfig<P> {
|
||||
return {
|
||||
id: "42-school",
|
||||
name: "42 School",
|
||||
type: "oauth",
|
||||
authorization: {
|
||||
url: "https://api.intra.42.fr/oauth/authorize",
|
||||
params: { scope: "public" },
|
||||
},
|
||||
token: "https://api.intra.42.fr/oauth/token",
|
||||
userinfo: "https://api.intra.42.fr/v2/me",
|
||||
profile(profile) {
|
||||
return {
|
||||
id: profile.id.toString(),
|
||||
name: profile.usual_full_name,
|
||||
email: profile.email,
|
||||
image: profile.image_url,
|
||||
}
|
||||
},
|
||||
options,
|
||||
}
|
||||
}
|
||||
128
packages/core/src/providers/apple.ts
Normal file
128
packages/core/src/providers/apple.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { OAuthConfig, OAuthUserConfig } from "."
|
||||
|
||||
/**
|
||||
* See more at:
|
||||
* [Retrieve the User's Information from Apple ID Servers
|
||||
](https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_rest_api/authenticating_users_with_sign_in_with_apple#3383773)
|
||||
*/
|
||||
export interface AppleProfile extends Record<string, any> {
|
||||
/**
|
||||
* The issuer registered claim identifies the principal that issued the identity token.
|
||||
* Since Apple generates the token, the value is `https://appleid.apple.com`.
|
||||
*/
|
||||
iss: "https://appleid.apple.com"
|
||||
/**
|
||||
* The audience registered claim identifies the recipient for which the identity token is intended.
|
||||
* Since the token is meant for your application, the value is the `client_id` from your developer account.
|
||||
*/
|
||||
aud: string
|
||||
/**
|
||||
* The issued at registered claim indicates the time at which Apple issued the identity token,
|
||||
* in terms of the number of seconds since Epoch, in UTC.
|
||||
*/
|
||||
iat: number
|
||||
|
||||
/**
|
||||
* The expiration time registered identifies the time on or after which the identity token expires,
|
||||
* in terms of number of seconds since Epoch, in UTC.
|
||||
* The value must be greater than the current date/time when verifying the token.
|
||||
*/
|
||||
exp: number
|
||||
/**
|
||||
* The subject registered claim identifies the principal that's the subject of the identity token.
|
||||
* Since this token is meant for your application, the value is the unique identifier for the user.
|
||||
*/
|
||||
sub: string
|
||||
/**
|
||||
* A String value used to associate a client session and the identity token.
|
||||
* This value mitigates replay attacks and is present only if passed during the authorization request.
|
||||
*/
|
||||
nonce: string
|
||||
|
||||
/**
|
||||
* A Boolean value that indicates whether the transaction is on a nonce-supported platform.
|
||||
* If you sent a nonce in the authorization request but don't see the nonce claim in the identity token,
|
||||
* check this claim to determine how to proceed.
|
||||
* If this claim returns true, you should treat nonce as mandatory and fail the transaction;
|
||||
* otherwise, you can proceed treating the nonce as options.
|
||||
*/
|
||||
nonce_supported: boolean
|
||||
|
||||
/**
|
||||
* A String value representing the user's email address.
|
||||
* The email address is either the user's real email address or the proxy address,
|
||||
* depending on their status private email relay service.
|
||||
*/
|
||||
email: string
|
||||
|
||||
/**
|
||||
* A String or Boolean value that indicates whether the service has verified the email.
|
||||
* The value of this claim is always true, because the servers only return verified email addresses.
|
||||
* The value can either be a String (`"true"`) or a Boolean (`true`).
|
||||
*/
|
||||
email_verified: "true" | true
|
||||
|
||||
/**
|
||||
* A String or Boolean value that indicates whether the email shared by the user is the proxy address.
|
||||
* The value can either be a String (`"true"` or `"false"`) or a Boolean (`true` or `false`).
|
||||
*/
|
||||
is_private_email: boolean | "true" | "false"
|
||||
|
||||
/**
|
||||
* An Integer value that indicates whether the user appears to be a real person.
|
||||
* Use the value of this claim to mitigate fraud. The possible values are: 0 (or Unsupported), 1 (or Unknown), 2 (or LikelyReal).
|
||||
* For more information, see [`ASUserDetectionStatus`](https://developer.apple.com/documentation/authenticationservices/asuserdetectionstatus).
|
||||
* This claim is present only on iOS 14 and later, macOS 11 and later, watchOS 7 and later, tvOS 14 and later;
|
||||
* the claim isn't present or supported for web-based apps.
|
||||
*/
|
||||
real_user_status: 0 | 1 | 2
|
||||
|
||||
/**
|
||||
* A String value representing the transfer identifier used to migrate users to your team.
|
||||
* This claim is present only during the 60-day transfer period after an you transfer an app.
|
||||
* For more information, see [Bringing New Apps and Users into Your Team](https://developer.apple.com/documentation/sign_in_with_apple/bringing_new_apps_and_users_into_your_team).
|
||||
*/
|
||||
transfer_sub: string
|
||||
at_hash: string
|
||||
auth_time: number
|
||||
}
|
||||
|
||||
export default function Apple<P extends AppleProfile>(
|
||||
options: Omit<OAuthUserConfig<P>, "clientSecret"> & {
|
||||
/**
|
||||
* Apple requires the client secret to be a JWT. You can generate one using the following script:
|
||||
* https://bal.so/apple-gen-secret
|
||||
*
|
||||
* Read more: [Creating the Client Secret
|
||||
](https://developer.apple.com/documentation/sign_in_with_apple/generate_and_validate_tokens#3262048)
|
||||
*/
|
||||
clientSecret: string
|
||||
}
|
||||
): OAuthConfig<P> {
|
||||
return {
|
||||
id: "apple",
|
||||
name: "Apple",
|
||||
type: "oidc",
|
||||
issuer: "https://appleid.apple.com",
|
||||
authorization: {
|
||||
params: { scope: "name email", response_mode: "form_post" },
|
||||
},
|
||||
profile(profile) {
|
||||
return {
|
||||
id: profile.sub,
|
||||
name: profile.name,
|
||||
email: profile.email,
|
||||
image: null,
|
||||
}
|
||||
},
|
||||
style: {
|
||||
logo: "/apple.svg",
|
||||
logoDark: "/apple-dark.svg",
|
||||
bg: "#fff",
|
||||
text: "#000",
|
||||
bgDark: "#000",
|
||||
textDark: "#fff",
|
||||
},
|
||||
options,
|
||||
}
|
||||
}
|
||||
44
packages/core/src/providers/atlassian.ts
Normal file
44
packages/core/src/providers/atlassian.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { OAuthConfig, OAuthUserConfig } from "."
|
||||
|
||||
interface AtlassianProfile extends Record<string, any> {
|
||||
account_id: string
|
||||
name: string
|
||||
email: string
|
||||
picture: string
|
||||
}
|
||||
|
||||
export default function Atlassian<P extends AtlassianProfile>(
|
||||
options: OAuthUserConfig<P>
|
||||
): OAuthConfig<P> {
|
||||
return {
|
||||
id: "atlassian",
|
||||
name: "Atlassian",
|
||||
type: "oauth",
|
||||
authorization: {
|
||||
url: "https://auth.atlassian.com/authorize",
|
||||
params: {
|
||||
audience: "api.atlassian.com",
|
||||
prompt: "consent",
|
||||
},
|
||||
},
|
||||
token: "https://auth.atlassian.com/oauth/token",
|
||||
userinfo: "https://api.atlassian.com/me",
|
||||
profile(profile) {
|
||||
return {
|
||||
id: profile.account_id,
|
||||
name: profile.name,
|
||||
email: profile.email,
|
||||
image: profile.picture,
|
||||
}
|
||||
},
|
||||
style: {
|
||||
logo: "/atlassian.svg",
|
||||
logoDark: "/atlassian-dark.svg",
|
||||
bg: "#0052cc",
|
||||
text: "#fff",
|
||||
bgDark: "#fff",
|
||||
textDark: "#0052cc",
|
||||
},
|
||||
options,
|
||||
}
|
||||
}
|
||||
27
packages/core/src/providers/auth0.ts
Normal file
27
packages/core/src/providers/auth0.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { OAuthConfig, OAuthUserConfig } from "."
|
||||
|
||||
export interface Auth0Profile extends Record<string, any> {
|
||||
sub: string
|
||||
nickname: string
|
||||
email: string
|
||||
picture: string
|
||||
}
|
||||
|
||||
export default function Auth0<P extends Auth0Profile>(
|
||||
options: OAuthUserConfig<P>
|
||||
): OAuthConfig<P> {
|
||||
return {
|
||||
id: "auth0",
|
||||
name: "Auth0",
|
||||
type: "oidc",
|
||||
style: {
|
||||
logo: "/auth0.svg",
|
||||
logoDark: "/auth0-dark.svg",
|
||||
bg: "#fff",
|
||||
text: "#EB5424",
|
||||
bgDark: "#EB5424",
|
||||
textDark: "#fff",
|
||||
},
|
||||
options,
|
||||
}
|
||||
}
|
||||
33
packages/core/src/providers/authentik.ts
Normal file
33
packages/core/src/providers/authentik.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { OAuthConfig, OAuthUserConfig } from "."
|
||||
|
||||
export interface AuthentikProfile extends Record<string, any> {
|
||||
iss: string
|
||||
sub: string
|
||||
aud: string
|
||||
exp: number
|
||||
iat: number
|
||||
auth_time: number
|
||||
acr: string
|
||||
c_hash: string
|
||||
nonce: string
|
||||
at_hash: string
|
||||
email: string
|
||||
email_verified: boolean
|
||||
name: string
|
||||
given_name: string
|
||||
family_name: string
|
||||
preferred_username: string
|
||||
nickname: string
|
||||
groups: string[]
|
||||
}
|
||||
|
||||
export default function Authentik<P extends AuthentikProfile>(
|
||||
options: OAuthUserConfig<P>
|
||||
): OAuthConfig<P> {
|
||||
return {
|
||||
id: "authentik",
|
||||
name: "Authentik",
|
||||
type: "oidc",
|
||||
options,
|
||||
}
|
||||
}
|
||||
50
packages/core/src/providers/azure-ad-b2c.ts
Normal file
50
packages/core/src/providers/azure-ad-b2c.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { OAuthConfig, OAuthUserConfig } from "."
|
||||
|
||||
export interface AzureB2CProfile extends Record<string, any> {
|
||||
exp: number
|
||||
nbf: number
|
||||
ver: string
|
||||
iss: string
|
||||
sub: string
|
||||
aud: string
|
||||
iat: number
|
||||
auth_time: number
|
||||
oid: string
|
||||
country: string
|
||||
name: string
|
||||
postalCode: string
|
||||
emails: string[]
|
||||
tfp: string
|
||||
}
|
||||
|
||||
export default function AzureADB2C<P extends AzureB2CProfile>(
|
||||
options: OAuthUserConfig<P> & {
|
||||
primaryUserFlow?: string
|
||||
tenantId?: string
|
||||
}
|
||||
): OAuthConfig<P> {
|
||||
const { tenantId, primaryUserFlow } = options
|
||||
options.issuer ??= `https://${tenantId}.b2clogin.com/${tenantId}.onmicrosoft.com/${primaryUserFlow}/v2.0`
|
||||
return {
|
||||
id: "azure-ad-b2c",
|
||||
name: "Azure Active Directory B2C",
|
||||
type: "oidc",
|
||||
profile(profile) {
|
||||
return {
|
||||
id: profile.sub,
|
||||
name: profile.name,
|
||||
email: profile.emails[0],
|
||||
image: null,
|
||||
}
|
||||
},
|
||||
style: {
|
||||
logo: "/azure.svg",
|
||||
logoDark: "/azure-dark.svg",
|
||||
bg: "#fff",
|
||||
text: "#0072c6",
|
||||
bgDark: "#0072c6",
|
||||
textDark: "#fff",
|
||||
},
|
||||
options,
|
||||
}
|
||||
}
|
||||
64
packages/core/src/providers/azure-ad.ts
Normal file
64
packages/core/src/providers/azure-ad.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import type { OAuthConfig, OAuthUserConfig } from "."
|
||||
|
||||
export interface AzureADProfile extends Record<string, any> {
|
||||
sub: string
|
||||
nickname: string
|
||||
email: string
|
||||
picture: string
|
||||
}
|
||||
|
||||
export default function AzureAD<P extends AzureADProfile>(
|
||||
options: OAuthUserConfig<P> & {
|
||||
/**
|
||||
* https://docs.microsoft.com/en-us/graph/api/profilephoto-get?view=graph-rest-1.0#examples
|
||||
* @default 48
|
||||
*/
|
||||
profilePhotoSize?: 48 | 64 | 96 | 120 | 240 | 360 | 432 | 504 | 648
|
||||
/** @default "common" */
|
||||
tenantId?: string
|
||||
}
|
||||
): OAuthConfig<P> {
|
||||
const { tenantId = "common", profilePhotoSize = 48, ...rest } = options
|
||||
rest.issuer ??= `https://login.microsoftonline.com/${tenantId}/v2.0`
|
||||
return {
|
||||
id: "azure-ad",
|
||||
name: "Azure Active Directory",
|
||||
type: "oidc",
|
||||
wellKnown: `${rest.issuer}}/.well-known/openid-configuration?appid=${options.clientId}`,
|
||||
async profile(profile, tokens) {
|
||||
// https://docs.microsoft.com/en-us/graph/api/profilephoto-get?view=graph-rest-1.0#examples
|
||||
const response = await fetch(
|
||||
`https://graph.microsoft.com/v1.0/me/photos/${profilePhotoSize}x${profilePhotoSize}/$value`,
|
||||
{ headers: { Authorization: `Bearer ${tokens.access_token}` } }
|
||||
)
|
||||
|
||||
// Confirm that profile photo was returned
|
||||
if (response.ok) {
|
||||
const pictureBuffer = await response.arrayBuffer()
|
||||
const pictureBase64 = Buffer.from(pictureBuffer).toString("base64")
|
||||
return {
|
||||
id: profile.sub,
|
||||
name: profile.name,
|
||||
email: profile.email,
|
||||
image: `data:image/jpeg;base64, ${pictureBase64}`,
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
id: profile.sub,
|
||||
name: profile.name,
|
||||
email: profile.email,
|
||||
image: null,
|
||||
}
|
||||
}
|
||||
},
|
||||
style: {
|
||||
logo: "/azure.svg",
|
||||
logoDark: "/azure-dark.svg",
|
||||
bg: "#fff",
|
||||
text: "#0072c6",
|
||||
bgDark: "#0072c6",
|
||||
textDark: "#fff",
|
||||
},
|
||||
options: rest,
|
||||
}
|
||||
}
|
||||
38
packages/core/src/providers/battlenet.ts
Normal file
38
packages/core/src/providers/battlenet.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { OAuthConfig, OAuthUserConfig } from "."
|
||||
|
||||
export interface BattleNetProfile extends Record<string, any> {
|
||||
sub: string
|
||||
battle_tag: string
|
||||
}
|
||||
|
||||
/** See the [available regions](https://develop.battle.net/documentation/guides/regionality-and-apis) */
|
||||
export type BattleNetIssuer =
|
||||
| "https://www.battlenet.com.cn/oauth"
|
||||
| `https://${"us" | "eu" | "kr" | "tw"}.battle.net/oauth`
|
||||
|
||||
export default function BattleNet<P extends BattleNetProfile>(
|
||||
options: OAuthUserConfig<P> & { issuer: BattleNetIssuer }
|
||||
): OAuthConfig<P> {
|
||||
return {
|
||||
id: "battlenet",
|
||||
name: "Battle.net",
|
||||
type: "oidc",
|
||||
profile(profile) {
|
||||
return {
|
||||
id: profile.sub,
|
||||
name: profile.battle_tag,
|
||||
email: null,
|
||||
image: null,
|
||||
}
|
||||
},
|
||||
style: {
|
||||
logo: "/battlenet.svg",
|
||||
logoDark: "/battlenet-dark.svg",
|
||||
bg: "#fff",
|
||||
text: "#148eff",
|
||||
bgDark: "#148eff",
|
||||
textDark: "#fff",
|
||||
},
|
||||
options,
|
||||
}
|
||||
}
|
||||
28
packages/core/src/providers/box.js
Normal file
28
packages/core/src/providers/box.js
Normal file
@@ -0,0 +1,28 @@
|
||||
/** @type {import(".").OAuthProvider} */
|
||||
export default function Box(options) {
|
||||
return {
|
||||
id: "box",
|
||||
name: "Box",
|
||||
type: "oauth",
|
||||
authorization: "https://account.box.com/api/oauth2/authorize",
|
||||
token: "https://api.box.com/oauth2/token",
|
||||
userinfo: "https://api.box.com/2.0/users/me",
|
||||
profile(profile) {
|
||||
return {
|
||||
id: profile.id,
|
||||
name: profile.name,
|
||||
email: profile.login,
|
||||
image: profile.avatar_url,
|
||||
}
|
||||
},
|
||||
style: {
|
||||
logo: "/box.svg",
|
||||
logoDark: "/box-dark.svg",
|
||||
bg: "#fff",
|
||||
text: "#0075C9",
|
||||
bgDark: "#0075C9",
|
||||
textDark: "#fff",
|
||||
},
|
||||
options,
|
||||
}
|
||||
}
|
||||
33
packages/core/src/providers/boxyhq-saml.ts
Normal file
33
packages/core/src/providers/boxyhq-saml.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { OAuthConfig, OAuthUserConfig } from "."
|
||||
|
||||
export interface BoxyHQSAMLProfile extends Record<string, any> {
|
||||
id: string
|
||||
email: string
|
||||
firstName?: string
|
||||
lastName?: string
|
||||
}
|
||||
|
||||
export default function SAMLJackson<P extends BoxyHQSAMLProfile>(
|
||||
options: OAuthUserConfig<P>
|
||||
): OAuthConfig<P> {
|
||||
return {
|
||||
id: "boxyhq-saml",
|
||||
name: "BoxyHQ SAML",
|
||||
type: "oauth",
|
||||
authorization: {
|
||||
url: `${options.issuer}/api/oauth/authorize`,
|
||||
params: { provider: "saml" },
|
||||
},
|
||||
token: `${options.issuer}/api/oauth/token`,
|
||||
userinfo: `${options.issuer}/api/oauth/userinfo`,
|
||||
profile(profile) {
|
||||
return {
|
||||
id: profile.id,
|
||||
email: profile.email,
|
||||
name: [profile.firstName, profile.lastName].filter(Boolean).join(" "),
|
||||
image: null,
|
||||
}
|
||||
},
|
||||
options,
|
||||
}
|
||||
}
|
||||
25
packages/core/src/providers/bungie.js
Normal file
25
packages/core/src/providers/bungie.js
Normal file
@@ -0,0 +1,25 @@
|
||||
/** @type {import(".").OAuthProvider} */
|
||||
export default function Bungie(options) {
|
||||
return {
|
||||
id: "bungie",
|
||||
name: "Bungie",
|
||||
type: "oauth",
|
||||
authorization: "https://www.bungie.net/en/OAuth/Authorize?reauth=true",
|
||||
token: "https://www.bungie.net/platform/app/oauth/token/",
|
||||
userinfo:
|
||||
"https://www.bungie.net/platform/User/GetBungieAccount/{membershipId}/254/",
|
||||
profile(profile) {
|
||||
const { bungieNetUser: user } = profile.Response
|
||||
|
||||
return {
|
||||
id: user.membershipId,
|
||||
name: user.displayName,
|
||||
email: null,
|
||||
image: `https://www.bungie.net${
|
||||
user.profilePicturePath.startsWith("/") ? "" : "/"
|
||||
}${user.profilePicturePath}`,
|
||||
}
|
||||
},
|
||||
options,
|
||||
}
|
||||
}
|
||||
27
packages/core/src/providers/cognito.ts
Normal file
27
packages/core/src/providers/cognito.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { OAuthConfig, OAuthUserConfig } from "."
|
||||
|
||||
export interface CognitoProfile extends Record<string, any> {
|
||||
sub: string
|
||||
name: string
|
||||
email: string
|
||||
picture: string
|
||||
}
|
||||
|
||||
export default function Cognito<P extends CognitoProfile>(
|
||||
options: OAuthUserConfig<P>
|
||||
): OAuthConfig<P> {
|
||||
return {
|
||||
id: "cognito",
|
||||
name: "Cognito",
|
||||
type: "oidc",
|
||||
style: {
|
||||
logo: "/cognito.svg",
|
||||
logoDark: "/cognito.svg",
|
||||
bg: "#fff",
|
||||
text: "#C17B9E",
|
||||
bgDark: "#fff",
|
||||
textDark: "#C17B9E",
|
||||
},
|
||||
options,
|
||||
}
|
||||
}
|
||||
21
packages/core/src/providers/coinbase.js
Normal file
21
packages/core/src/providers/coinbase.js
Normal file
@@ -0,0 +1,21 @@
|
||||
/** @type {import(".").OAuthProvider} */
|
||||
export default function Coinbase(options) {
|
||||
return {
|
||||
id: "coinbase",
|
||||
name: "Coinbase",
|
||||
type: "oauth",
|
||||
authorization:
|
||||
"https://www.coinbase.com/oauth/authorize?scope=wallet:user:email+wallet:user:read",
|
||||
token: "https://api.coinbase.com/oauth/token",
|
||||
userinfo: "https://api.coinbase.com/v2/user",
|
||||
profile(profile) {
|
||||
return {
|
||||
id: profile.data.id,
|
||||
name: profile.data.name,
|
||||
email: profile.data.email,
|
||||
image: profile.data.avatar_url,
|
||||
}
|
||||
},
|
||||
options,
|
||||
}
|
||||
}
|
||||
44
packages/core/src/providers/credentials.ts
Normal file
44
packages/core/src/providers/credentials.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { CommonProviderOptions } from "."
|
||||
import type { Awaitable, RequestInternal, User } from ".."
|
||||
|
||||
export interface CredentialInput {
|
||||
label?: string
|
||||
type?: string
|
||||
value?: string
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
export interface CredentialsConfig<
|
||||
C extends Record<string, CredentialInput> = Record<string, CredentialInput>
|
||||
> extends CommonProviderOptions {
|
||||
type: "credentials"
|
||||
credentials: C
|
||||
authorize: (
|
||||
credentials: Record<keyof C, string> | undefined,
|
||||
req: Pick<RequestInternal, "body" | "query" | "headers" | "method">
|
||||
) => Awaitable<User | null>
|
||||
}
|
||||
|
||||
export type CredentialsProvider = <C extends Record<string, CredentialInput>>(
|
||||
options: Partial<CredentialsConfig<C>>
|
||||
) => CredentialsConfig<C>
|
||||
|
||||
export type CredentialsProviderType = "Credentials"
|
||||
|
||||
type UserCredentialsConfig<C extends Record<string, CredentialInput>> = Partial<
|
||||
Omit<CredentialsConfig<C>, "options">
|
||||
> &
|
||||
Pick<CredentialsConfig<C>, "authorize" | "credentials">
|
||||
|
||||
export default function Credentials<
|
||||
C extends Record<string, CredentialInput> = Record<string, CredentialInput>
|
||||
>(options: UserCredentialsConfig<C>): CredentialsConfig<C> {
|
||||
return {
|
||||
id: "credentials",
|
||||
name: "Credentials",
|
||||
type: "credentials",
|
||||
credentials: {} as any,
|
||||
authorize: () => null,
|
||||
options,
|
||||
}
|
||||
}
|
||||
57
packages/core/src/providers/discord.ts
Normal file
57
packages/core/src/providers/discord.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { OAuthConfig, OAuthUserConfig } from "."
|
||||
|
||||
export interface DiscordProfile extends Record<string, any> {
|
||||
accent_color: number
|
||||
avatar: string
|
||||
banner: string
|
||||
banner_color: string
|
||||
discriminator: string
|
||||
email: string
|
||||
flags: number
|
||||
id: string
|
||||
image_url: string
|
||||
locale: string
|
||||
mfa_enabled: boolean
|
||||
premium_type: number
|
||||
public_flags: number
|
||||
username: string
|
||||
verified: boolean
|
||||
}
|
||||
|
||||
export default function Discord<P extends DiscordProfile>(
|
||||
options: OAuthUserConfig<P>
|
||||
): OAuthConfig<P> {
|
||||
return {
|
||||
id: "discord",
|
||||
name: "Discord",
|
||||
type: "oauth",
|
||||
authorization:
|
||||
"https://discord.com/api/oauth2/authorize?scope=identify+email",
|
||||
token: "https://discord.com/api/oauth2/token",
|
||||
userinfo: "https://discord.com/api/users/@me",
|
||||
profile(profile) {
|
||||
if (profile.avatar === null) {
|
||||
const defaultAvatarNumber = parseInt(profile.discriminator) % 5
|
||||
profile.image_url = `https://cdn.discordapp.com/embed/avatars/${defaultAvatarNumber}.png`
|
||||
} else {
|
||||
const format = profile.avatar.startsWith("a_") ? "gif" : "png"
|
||||
profile.image_url = `https://cdn.discordapp.com/avatars/${profile.id}/${profile.avatar}.${format}`
|
||||
}
|
||||
return {
|
||||
id: profile.id,
|
||||
name: profile.username,
|
||||
email: profile.email,
|
||||
image: profile.image_url,
|
||||
}
|
||||
},
|
||||
style: {
|
||||
logo: "/discord.svg",
|
||||
logoDark: "/discord-dark.svg",
|
||||
bg: "#fff",
|
||||
text: "#7289DA",
|
||||
bgDark: "#7289DA",
|
||||
textDark: "#fff",
|
||||
},
|
||||
options,
|
||||
}
|
||||
}
|
||||
50
packages/core/src/providers/dropbox.js
Normal file
50
packages/core/src/providers/dropbox.js
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* @param {import("../core").Provider} options
|
||||
* @example
|
||||
*
|
||||
* ```js
|
||||
* // pages/api/auth/[...nextauth].js
|
||||
* import Providers from `next-auth/providers`
|
||||
* ...
|
||||
* providers: [
|
||||
* Providers.Dropbox({
|
||||
* clientId: process.env.DROPBOX_CLIENT_ID,
|
||||
* clientSecret: process.env.DROPBOX_CLIENT_SECRET
|
||||
* })
|
||||
* ]
|
||||
* ...
|
||||
*
|
||||
* // pages/index
|
||||
* import { signIn } from "next-auth/react"
|
||||
* ...
|
||||
* <button onClick={() => signIn("dropbox")}>
|
||||
* Sign in
|
||||
* </button>
|
||||
* ...
|
||||
* ```
|
||||
* *Resources:*
|
||||
* - [NextAuth.js Documentation](https://next-auth.js.org/providers/dropbox)
|
||||
* - [Dropbox Documentation](https://developers.dropbox.com/oauth-guide)
|
||||
* - [Configuration](https://www.dropbox.com/developers/apps)
|
||||
*/
|
||||
/** @type {import(".").OAuthProvider} */
|
||||
export default function Dropbox(options) {
|
||||
return {
|
||||
id: "dropbox",
|
||||
name: "Dropbox",
|
||||
type: "oauth",
|
||||
authorization:
|
||||
"https://www.dropbox.com/oauth2/authorize?token_access_type=offline&scope=account_info.read",
|
||||
token: "https://api.dropboxapi.com/oauth2/token",
|
||||
userinfo: "https://api.dropboxapi.com/2/users/get_current_account",
|
||||
profile(profile) {
|
||||
return {
|
||||
id: profile.account_id,
|
||||
name: profile.name.display_name,
|
||||
email: profile.email,
|
||||
image: profile.profile_photo_url,
|
||||
}
|
||||
},
|
||||
options,
|
||||
}
|
||||
}
|
||||
19
packages/core/src/providers/duende-identity-server6.ts
Normal file
19
packages/core/src/providers/duende-identity-server6.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { OAuthConfig, OAuthUserConfig } from "./oauth"
|
||||
|
||||
export interface DuendeISUser extends Record<string, any> {
|
||||
email: string
|
||||
id: string
|
||||
name: string
|
||||
verified: boolean
|
||||
}
|
||||
|
||||
export default function DuendeIdentityServer6<P extends DuendeISUser>(
|
||||
options: OAuthUserConfig<P>
|
||||
): OAuthConfig<P> {
|
||||
return {
|
||||
id: "duende-identityserver6",
|
||||
name: "DuendeIdentityServer6",
|
||||
type: "oidc",
|
||||
options,
|
||||
}
|
||||
}
|
||||
166
packages/core/src/providers/email.ts
Normal file
166
packages/core/src/providers/email.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { createTransport } from "nodemailer"
|
||||
|
||||
import type { CommonProviderOptions } from "."
|
||||
import type { Options as SMTPTransportOptions } from "nodemailer/lib/smtp-transport"
|
||||
import type { Awaitable, Theme } from ".."
|
||||
|
||||
export interface SendVerificationRequestParams {
|
||||
identifier: string
|
||||
url: string
|
||||
expires: Date
|
||||
provider: EmailConfig
|
||||
token: string
|
||||
theme: Theme
|
||||
}
|
||||
|
||||
export interface EmailConfig extends CommonProviderOptions {
|
||||
type: "email"
|
||||
// TODO: Make use of https://www.typescriptlang.org/docs/handbook/2/template-literal-types.html
|
||||
server: string | SMTPTransportOptions
|
||||
/** @default "NextAuth <no-reply@example.com>" */
|
||||
from?: string
|
||||
/**
|
||||
* How long until the e-mail can be used to log the user in,
|
||||
* in seconds. Defaults to 1 day
|
||||
* @default 86400
|
||||
*/
|
||||
maxAge?: number
|
||||
/** [Documentation](https://next-auth.js.org/providers/email#customizing-emails) */
|
||||
sendVerificationRequest: (
|
||||
params: SendVerificationRequestParams
|
||||
) => Awaitable<void>
|
||||
/**
|
||||
* By default, we are generating a random verification token.
|
||||
* You can make it predictable or modify it as you like with this method.
|
||||
* @example
|
||||
* ```js
|
||||
* Providers.Email({
|
||||
* async generateVerificationToken() {
|
||||
* return "ABC123"
|
||||
* }
|
||||
* })
|
||||
* ```
|
||||
* [Documentation](https://next-auth.js.org/providers/email#customizing-the-verification-token)
|
||||
*/
|
||||
generateVerificationToken?: () => Awaitable<string>
|
||||
/** If defined, it is used to hash the verification token when saving to the database . */
|
||||
secret?: string
|
||||
/**
|
||||
* Normalizes the user input before sending the verification request.
|
||||
*
|
||||
* ⚠️ Always make sure this method returns a single email address.
|
||||
*
|
||||
* @note Technically, the part of the email address local mailbox element
|
||||
* (everything before the `@` symbol) should be treated as 'case sensitive'
|
||||
* according to RFC 2821, but in practice this causes more problems than
|
||||
* it solves, e.g.: when looking up users by e-mail from databases.
|
||||
* By default, we treat email addresses as all lower case,
|
||||
* but you can override this function to change this behavior.
|
||||
*
|
||||
* [Documentation](https://next-auth.js.org/providers/email#normalizing-the-e-mail-address) | [RFC 2821](https://tools.ietf.org/html/rfc2821) | [Email syntax](https://en.wikipedia.org/wiki/Email_address#Syntax)
|
||||
*/
|
||||
normalizeIdentifier?: (identifier: string) => string
|
||||
options: EmailUserConfig
|
||||
}
|
||||
|
||||
export type EmailUserConfig = Partial<Omit<EmailConfig, "options">>
|
||||
|
||||
export type EmailProvider = (options: EmailUserConfig) => EmailConfig
|
||||
|
||||
// TODO: Rename to Token provider
|
||||
// when started working on https://github.com/nextauthjs/next-auth/discussions/1465
|
||||
export type EmailProviderType = "Email"
|
||||
|
||||
export default function Email(options: EmailUserConfig): EmailConfig {
|
||||
return {
|
||||
id: "email",
|
||||
type: "email",
|
||||
name: "Email",
|
||||
// Server can be an SMTP connection string or a nodemailer config object
|
||||
server: { host: "localhost", port: 25, auth: { user: "", pass: "" } },
|
||||
from: "NextAuth <no-reply@example.com>",
|
||||
maxAge: 24 * 60 * 60,
|
||||
async sendVerificationRequest(params) {
|
||||
const { identifier, url, provider, theme } = params
|
||||
const { host } = new URL(url)
|
||||
const transport = createTransport(provider.server)
|
||||
const result = await transport.sendMail({
|
||||
to: identifier,
|
||||
from: provider.from,
|
||||
subject: `Sign in to ${host}`,
|
||||
text: text({ url, host }),
|
||||
html: html({ url, host, theme }),
|
||||
})
|
||||
const failed = result.rejected.concat(result.pending).filter(Boolean)
|
||||
if (failed.length) {
|
||||
throw new Error(`Email (${failed.join(", ")}) could not be sent`)
|
||||
}
|
||||
},
|
||||
options,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Email HTML body
|
||||
* Insert invisible space into domains from being turned into a hyperlink by email
|
||||
* clients like Outlook and Apple mail, as this is confusing because it seems
|
||||
* like they are supposed to click on it to sign in.
|
||||
*
|
||||
* @note We don't add the email address to avoid needing to escape it, if you do, remember to sanitize it!
|
||||
*/
|
||||
function html(params: { url: string; host: string; theme: Theme }) {
|
||||
const { url, host, theme } = params
|
||||
|
||||
const escapedHost = host.replace(/\./g, "​.")
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
const brandColor = theme.brandColor || "#346df1"
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
const buttonText = theme.buttonText || "#fff"
|
||||
|
||||
const color = {
|
||||
background: "#f9f9f9",
|
||||
text: "#444",
|
||||
mainBackground: "#fff",
|
||||
buttonBackground: brandColor,
|
||||
buttonBorder: brandColor,
|
||||
buttonText,
|
||||
}
|
||||
|
||||
return `
|
||||
<body style="background: ${color.background};">
|
||||
<table width="100%" border="0" cellspacing="20" cellpadding="0"
|
||||
style="background: ${color.mainBackground}; max-width: 600px; margin: auto; border-radius: 10px;">
|
||||
<tr>
|
||||
<td align="center"
|
||||
style="padding: 10px 0px; font-size: 22px; font-family: Helvetica, Arial, sans-serif; color: ${color.text};">
|
||||
Sign in to <strong>${escapedHost}</strong>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="padding: 20px 0;">
|
||||
<table border="0" cellspacing="0" cellpadding="0">
|
||||
<tr>
|
||||
<td align="center" style="border-radius: 5px;" bgcolor="${color.buttonBackground}"><a href="${url}"
|
||||
target="_blank"
|
||||
style="font-size: 18px; font-family: Helvetica, Arial, sans-serif; color: ${color.buttonText}; text-decoration: none; border-radius: 5px; padding: 10px 20px; border: 1px solid ${color.buttonBorder}; display: inline-block; font-weight: bold;">Sign
|
||||
in</a></td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"
|
||||
style="padding: 0px 0px 10px 0px; font-size: 16px; line-height: 22px; font-family: Helvetica, Arial, sans-serif; color: ${color.text};">
|
||||
If you did not request this email you can safely ignore it.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
`
|
||||
}
|
||||
|
||||
/** Email Text body (fallback for email clients that don't render HTML, e.g. feature phones) */
|
||||
function text({ url, host }: { url: string; host: string }) {
|
||||
return `Sign in to ${host}\n${url}\n\n`
|
||||
}
|
||||
33
packages/core/src/providers/eveonline.ts
Normal file
33
packages/core/src/providers/eveonline.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { OAuthConfig, OAuthUserConfig } from "."
|
||||
|
||||
export interface EVEOnlineProfile extends Record<string, any> {
|
||||
CharacterID: number
|
||||
CharacterName: string
|
||||
ExpiresOn: string
|
||||
Scopes: string
|
||||
TokenType: string
|
||||
CharacterOwnerHash: string
|
||||
IntellectualProperty: string
|
||||
}
|
||||
|
||||
export default function EVEOnline<P extends EVEOnlineProfile>(
|
||||
options: OAuthUserConfig<P>
|
||||
): OAuthConfig<P> {
|
||||
return {
|
||||
id: "eveonline",
|
||||
name: "EVE Online",
|
||||
type: "oauth",
|
||||
authorization: "https://login.eveonline.com/v2/oauth/authorize?scope=publicData",
|
||||
token: "https://login.eveonline.com/v2/oauth/token",
|
||||
userinfo: "https://login.eveonline.com/oauth/verify",
|
||||
profile(profile) {
|
||||
return {
|
||||
id: String(profile.CharacterID),
|
||||
name: profile.CharacterName,
|
||||
email: null,
|
||||
image: `https://image.eveonline.com/Character/${profile.CharacterID}_128.jpg`,
|
||||
}
|
||||
},
|
||||
options,
|
||||
}
|
||||
}
|
||||
51
packages/core/src/providers/facebook.ts
Normal file
51
packages/core/src/providers/facebook.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { OAuthConfig, OAuthUserConfig } from "."
|
||||
|
||||
interface FacebookPictureData {
|
||||
url: string
|
||||
}
|
||||
|
||||
interface FacebookPicture {
|
||||
data: FacebookPictureData
|
||||
}
|
||||
export interface FacebookProfile extends Record<string, any> {
|
||||
id: string
|
||||
picture: FacebookPicture
|
||||
}
|
||||
|
||||
export default function Facebook<P extends FacebookProfile>(
|
||||
options: OAuthUserConfig<P>
|
||||
): OAuthConfig<P> {
|
||||
return {
|
||||
id: "facebook",
|
||||
name: "Facebook",
|
||||
type: "oauth",
|
||||
authorization: "https://www.facebook.com/v15.0/dialog/oauth?scope=email",
|
||||
token: "https://graph.facebook.com/oauth/access_token",
|
||||
userinfo: {
|
||||
// https://developers.facebook.com/docs/graph-api/reference/user/#fields
|
||||
url: "https://graph.facebook.com/me?fields=id,name,email,picture",
|
||||
async request({ tokens, provider }) {
|
||||
return await fetch(provider.userinfo?.url as URL, {
|
||||
headers: { Authorization: `Bearer ${tokens.access_token}` },
|
||||
}).then(async (res) => await res.json())
|
||||
},
|
||||
},
|
||||
profile(profile: P) {
|
||||
return {
|
||||
id: profile.id,
|
||||
name: profile.name,
|
||||
email: profile.email,
|
||||
image: profile.picture.data.url,
|
||||
}
|
||||
},
|
||||
style: {
|
||||
logo: "/facebook.svg",
|
||||
logoDark: "/facebook-dark.svg",
|
||||
bg: "#fff",
|
||||
text: "#006aff",
|
||||
bgDark: "#006aff",
|
||||
textDark: "#fff",
|
||||
},
|
||||
options,
|
||||
}
|
||||
}
|
||||
25
packages/core/src/providers/faceit.js
Normal file
25
packages/core/src/providers/faceit.js
Normal file
@@ -0,0 +1,25 @@
|
||||
/** @type {import(".").OAuthProvider} */
|
||||
export default function FACEIT(options) {
|
||||
return {
|
||||
id: "faceit",
|
||||
name: "FACEIT",
|
||||
type: "oauth",
|
||||
authorization: "https://accounts.faceit.com/accounts?redirect_popup=true",
|
||||
headers: {
|
||||
Authorization: `Basic ${Buffer.from(
|
||||
`${options.clientId}:${options.clientSecret}`
|
||||
).toString("base64")}`,
|
||||
},
|
||||
token: "https://api.faceit.com/auth/v1/oauth/token",
|
||||
userinfo: "https://api.faceit.com/auth/v1/resources/userinfo",
|
||||
profile(profile) {
|
||||
return {
|
||||
id: profile.guid,
|
||||
name: profile.name,
|
||||
email: profile.email,
|
||||
image: profile.picture,
|
||||
}
|
||||
},
|
||||
options,
|
||||
}
|
||||
}
|
||||
38
packages/core/src/providers/foursquare.js
Normal file
38
packages/core/src/providers/foursquare.js
Normal file
@@ -0,0 +1,38 @@
|
||||
/** @type {import(".").OAuthProvider} */
|
||||
export default function Foursquare(options) {
|
||||
const { apiVersion = "20210801" } = options
|
||||
return {
|
||||
id: "foursquare",
|
||||
name: "Foursquare",
|
||||
type: "oauth",
|
||||
authorization: "https://foursquare.com/oauth2/authenticate",
|
||||
token: "https://foursquare.com/oauth2/access_token",
|
||||
userinfo: {
|
||||
url: `https://api.foursquare.com/v2/users/self?v=${apiVersion}`,
|
||||
request({ tokens, provider }) {
|
||||
const url = new URL(provider.userinfo.url)
|
||||
url.searchParams.append("oauth_token", tokens.access_token)
|
||||
return fetch(url).then((res) => res.json())
|
||||
},
|
||||
},
|
||||
profile({ response: { profile } }) {
|
||||
return {
|
||||
id: profile.id,
|
||||
name: `${profile.firstName} ${profile.lastName}`,
|
||||
email: profile.contact.email,
|
||||
image: profile.photo
|
||||
? `${profile.photo.prefix}original${profile.photo.suffix}`
|
||||
: null,
|
||||
}
|
||||
},
|
||||
style: {
|
||||
logo: "/foursquare.svg",
|
||||
logoDark: "/foursquare-dark.svg",
|
||||
bg: "#fff",
|
||||
text: "#000",
|
||||
bgDark: "#000",
|
||||
textDark: "#fff",
|
||||
},
|
||||
options,
|
||||
}
|
||||
}
|
||||
28
packages/core/src/providers/freshbooks.js
Normal file
28
packages/core/src/providers/freshbooks.js
Normal file
@@ -0,0 +1,28 @@
|
||||
/** @type {import(".").OAuthProvider} */
|
||||
export default function Freshbooks(options) {
|
||||
return {
|
||||
id: "freshbooks",
|
||||
name: "Freshbooks",
|
||||
type: "oauth",
|
||||
authorization: "https://auth.freshbooks.com/service/auth/oauth/authorize",
|
||||
token: "https://api.freshbooks.com/auth/oauth/token",
|
||||
userinfo: "https://api.freshbooks.com/auth/api/v1/users/me",
|
||||
async profile(profile) {
|
||||
return {
|
||||
id: profile.response.id,
|
||||
name: `${profile.response.first_name} ${profile.response.last_name}`,
|
||||
email: profile.response.email,
|
||||
image: null,
|
||||
}
|
||||
},
|
||||
style: {
|
||||
logo: "/freshbooks.svg",
|
||||
logoDark: "/freshbooks-dark.svg",
|
||||
bg: "#fff",
|
||||
text: "#0075dd",
|
||||
bgDark: "#0075dd",
|
||||
textDark: "#fff",
|
||||
},
|
||||
...options,
|
||||
}
|
||||
}
|
||||
50
packages/core/src/providers/fusionauth.ts
Normal file
50
packages/core/src/providers/fusionauth.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { OAuthConfig, OAuthUserConfig } from "./oauth"
|
||||
|
||||
/** This is the default openid signature returned from FusionAuth
|
||||
* it can be customized using [lambda functions](https://fusionauth.io/docs/v1/tech/lambdas)
|
||||
*/
|
||||
export interface FusionAuthProfile extends Record<string, any> {
|
||||
aud: string
|
||||
exp: number
|
||||
iat: number
|
||||
iss: string
|
||||
sub: string
|
||||
jti: string
|
||||
authenticationType: string
|
||||
email: string
|
||||
email_verified: boolean
|
||||
preferred_username: string
|
||||
at_hash: string
|
||||
c_hash: string
|
||||
scope: string
|
||||
sid: string
|
||||
}
|
||||
|
||||
export default function FusionAuth<P extends FusionAuthProfile>(
|
||||
// tenantId only needed if there is more than one tenant configured on the server
|
||||
options: OAuthUserConfig<P> & { tenantId?: string }
|
||||
): OAuthConfig<P> {
|
||||
return {
|
||||
id: "fusionauth",
|
||||
name: "FusionAuth",
|
||||
type: "oauth",
|
||||
wellKnown: options?.tenantId
|
||||
? `${options.issuer}/.well-known/openid-configuration?tenantId=${options.tenantId}`
|
||||
: `${options.issuer}/.well-known/openid-configuration`,
|
||||
authorization: {
|
||||
params: {
|
||||
scope: "openid offline_access",
|
||||
...(options?.tenantId && { tenantId: options.tenantId }),
|
||||
},
|
||||
},
|
||||
checks: ["pkce", "state"],
|
||||
profile(profile) {
|
||||
return {
|
||||
id: profile.sub,
|
||||
email: profile.email,
|
||||
name: profile?.preferred_username,
|
||||
}
|
||||
},
|
||||
options,
|
||||
}
|
||||
}
|
||||
112
packages/core/src/providers/github.ts
Normal file
112
packages/core/src/providers/github.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import type { OAuthConfig, OAuthUserConfig } from "."
|
||||
|
||||
/** @see https://docs.github.com/en/rest/users/users#get-the-authenticated-user */
|
||||
export interface GithubProfile extends Record<string, any> {
|
||||
login: string
|
||||
id: number
|
||||
node_id: string
|
||||
avatar_url: string
|
||||
gravatar_id: string | null
|
||||
url: string
|
||||
html_url: string
|
||||
followers_url: string
|
||||
following_url: string
|
||||
gists_url: string
|
||||
starred_url: string
|
||||
subscriptions_url: string
|
||||
organizations_url: string
|
||||
repos_url: string
|
||||
events_url: string
|
||||
received_events_url: string
|
||||
type: string
|
||||
site_admin: boolean
|
||||
name: string | null
|
||||
company: string | null
|
||||
blog: string | null
|
||||
location: string | null
|
||||
email: string | null
|
||||
hireable: boolean | null
|
||||
bio: string | null
|
||||
twitter_username?: string | null
|
||||
public_repos: number
|
||||
public_gists: number
|
||||
followers: number
|
||||
following: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
private_gists?: number
|
||||
total_private_repos?: number
|
||||
owned_private_repos?: number
|
||||
disk_usage?: number
|
||||
suspended_at?: string | null
|
||||
collaborators?: number
|
||||
two_factor_authentication: boolean
|
||||
plan?: {
|
||||
collaborators: number
|
||||
name: string
|
||||
space: number
|
||||
private_repos: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface GithubEmail extends Record<string, any> {
|
||||
email: string
|
||||
primary: boolean
|
||||
verified: boolean
|
||||
visibility: "public" | "private"
|
||||
}
|
||||
|
||||
export default function Github<P extends GithubProfile>(
|
||||
options: OAuthUserConfig<P>
|
||||
): OAuthConfig<P> {
|
||||
return {
|
||||
id: "github",
|
||||
name: "GitHub",
|
||||
type: "oauth",
|
||||
authorization: {
|
||||
url: "https://github.com/login/oauth/authorize",
|
||||
params: { scope: "read:user user:email" },
|
||||
},
|
||||
token: "https://github.com/login/oauth/access_token",
|
||||
userinfo: {
|
||||
url: "https://api.github.com/user",
|
||||
async request({ tokens, provider }) {
|
||||
const profile = await fetch(provider.userinfo?.url as URL, {
|
||||
headers: { Authorization: `Bearer ${tokens.access_token}` },
|
||||
}).then(async (res) => await res.json())
|
||||
|
||||
if (!profile.email) {
|
||||
// If the user does not have a public email, get another via the GitHub API
|
||||
// See https://docs.github.com/en/rest/users/emails#list-public-email-addresses-for-the-authenticated-user
|
||||
const res = await fetch("https://api.github.com/user/emails", {
|
||||
headers: { Authorization: `Bearer ${tokens.access_token}` },
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
const emails: GithubEmail[] = await res.json()
|
||||
profile.email = (emails.find((e) => e.primary) ?? emails[0]).email
|
||||
}
|
||||
}
|
||||
|
||||
return profile
|
||||
},
|
||||
},
|
||||
profile(profile) {
|
||||
return {
|
||||
id: profile.id.toString(),
|
||||
name: profile.name ?? profile.login,
|
||||
email: profile.email,
|
||||
image: profile.avatar_url,
|
||||
}
|
||||
},
|
||||
style: {
|
||||
logo: "/github.svg",
|
||||
logoDark: "/github-dark.svg",
|
||||
bg: "#fff",
|
||||
bgDark: "#000",
|
||||
text: "#000",
|
||||
textDark: "#fff",
|
||||
},
|
||||
options,
|
||||
}
|
||||
}
|
||||
76
packages/core/src/providers/gitlab.ts
Normal file
76
packages/core/src/providers/gitlab.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import type { OAuthConfig, OAuthUserConfig } from "."
|
||||
|
||||
export interface GitLabProfile extends Record<string, any> {
|
||||
id: number
|
||||
username: string
|
||||
email: string
|
||||
name: string
|
||||
state: string
|
||||
avatar_url: string
|
||||
web_url: string
|
||||
created_at: string
|
||||
bio: string
|
||||
location?: string
|
||||
public_email: string
|
||||
skype: string
|
||||
linkedin: string
|
||||
twitter: string
|
||||
website_url: string
|
||||
organization: string
|
||||
job_title: string
|
||||
pronouns: string
|
||||
bot: boolean
|
||||
work_information?: string
|
||||
followers: number
|
||||
following: number
|
||||
local_time: string
|
||||
last_sign_in_at: string
|
||||
confirmed_at: string
|
||||
theme_id: number
|
||||
last_activity_on: string
|
||||
color_scheme_id: number
|
||||
projects_limit: number
|
||||
current_sign_in_at: string
|
||||
identities: Array<{
|
||||
provider: string
|
||||
extern_uid: string
|
||||
}>
|
||||
can_create_group: boolean
|
||||
can_create_project: boolean
|
||||
two_factor_enabled: boolean
|
||||
external: boolean
|
||||
private_profile: boolean
|
||||
commit_email: string
|
||||
shared_runners_minutes_limit: number
|
||||
extra_shared_runners_minutes_limit: number
|
||||
}
|
||||
|
||||
export default function GitLab<P extends GitLabProfile>(
|
||||
options: OAuthUserConfig<P>
|
||||
): OAuthConfig<P> {
|
||||
return {
|
||||
id: "gitlab",
|
||||
name: "GitLab",
|
||||
type: "oauth",
|
||||
authorization: "https://gitlab.com/oauth/authorize?scope=read_user",
|
||||
token: "https://gitlab.com/oauth/token",
|
||||
userinfo: "https://gitlab.com/api/v4/user",
|
||||
profile(profile) {
|
||||
return {
|
||||
id: profile.id.toString(),
|
||||
name: profile.name ?? profile.username,
|
||||
email: profile.email,
|
||||
image: profile.avatar_url,
|
||||
}
|
||||
},
|
||||
style: {
|
||||
logo: "/gitlab.svg",
|
||||
logoDark: "/gitlab-dark.svg",
|
||||
bg: "#fff",
|
||||
text: "#FC6D26",
|
||||
bgDark: "#FC6D26",
|
||||
textDark: "#fff",
|
||||
},
|
||||
options,
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user