├── .eslintrc.js
├── .github
├── release.yml
└── workflows
│ ├── bump.yml
│ ├── main.yml
│ └── publish.yml
├── .gitignore
├── .vscode
└── settings.json
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── bun.lockb
├── config
├── jest.config.ts
└── jest
│ ├── babel.config.js
│ └── setup.ts
├── package.json
├── src
└── index.ts
├── test
└── index.test.ts
├── tsconfig.json
└── tsup.config.ts
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable unicorn/prefer-module */
2 | module.exports = {
3 | root: true,
4 | parser: "@typescript-eslint/parser",
5 | plugins: ["@typescript-eslint", "unicorn", "jest", "prettier"],
6 | extends: [
7 | "plugin:unicorn/recommended",
8 | "plugin:@typescript-eslint/recommended",
9 | "plugin:prettier/recommended",
10 | ],
11 | rules: {
12 | "prefer-const": "off",
13 | "@typescript-eslint/explicit-module-boundary-types": "off",
14 | "@typescript-eslint/no-non-null-assertion": "off",
15 | "no-unused-vars": "off",
16 | "no-var": "off",
17 | "unicorn/no-null": "off",
18 | "unicorn/prefer-node-protocol": "off",
19 | "unicorn/filename-case": "off",
20 | "unicorn/prevent-abbreviations": "off",
21 | },
22 | };
23 |
--------------------------------------------------------------------------------
/.github/release.yml:
--------------------------------------------------------------------------------
1 | changelog:
2 | categories:
3 | - title: Documentation Changes
4 | labels:
5 | - documentation
6 | - title: New Features
7 | labels:
8 | - enhancement
9 | - title: Bug Fixes
10 | labels:
11 | - bug
12 | - title: Other Changes
13 | labels:
14 | - "*"
15 |
--------------------------------------------------------------------------------
/.github/workflows/bump.yml:
--------------------------------------------------------------------------------
1 | name: 🔼 Bump version
2 |
3 | on:
4 | workflow_dispatch:
5 | inputs:
6 | version:
7 | description: 'Type of version (major / minor / patch)'
8 | required: true
9 |
10 | jobs:
11 | bump-version:
12 | name: 🔼 Bump version
13 | runs-on: ubuntu-latest
14 | steps:
15 | - name: ⬇️ Checkout repo
16 | uses: actions/checkout@v4
17 | with:
18 | ssh-key: ${{ secrets.DEPLOY_KEY }}
19 |
20 | - name: ⎔ Setup node
21 | uses: actions/setup-node@v4
22 | with:
23 | node-version: '20.x'
24 |
25 | - name: 🧅 Setup bun
26 | uses: oven-sh/setup-bun@v1
27 |
28 | - name: 📥 Download deps
29 | run: bun install
30 |
31 | - name: ⚙️ Setup Git
32 | run: |
33 | git config user.name '${{ secrets.GIT_USER_NAME }}'
34 | git config user.email '${{ secrets.GIT_USER_EMAIL }}'
35 |
36 | - name: 🔼 Bump version
37 | run: npm version ${{ github.event.inputs.version }}
38 |
39 | - name: ☁️ Push latest version
40 | run: git push origin main --follow-tags
--------------------------------------------------------------------------------
/.github/workflows/main.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on: [push]
4 |
5 | jobs:
6 | lint:
7 | name: ⬣ ESLint
8 | runs-on: ubuntu-latest
9 | steps:
10 | - name: ⬇️ Checkout repo
11 | uses: actions/checkout@v4
12 |
13 | - name: ⎔ Setup node
14 | uses: actions/setup-node@v4
15 | with:
16 | node-version: '20.x'
17 |
18 | - name: 🧅 Setup bun
19 | uses: oven-sh/setup-bun@v1
20 |
21 | - name: 📥 Download deps
22 | run: bun install
23 |
24 | - name: 🔬 Lint
25 | run: bun run lint
26 |
27 | typecheck:
28 | name: ʦ TypeScript
29 | runs-on: ubuntu-latest
30 | steps:
31 | - name: ⬇️ Checkout repo
32 | uses: actions/checkout@v4
33 |
34 | - name: ⎔ Setup node
35 | uses: actions/setup-node@v4
36 | with:
37 | node-version: '20.x'
38 |
39 | - name: 🧅 Setup bun
40 | uses: oven-sh/setup-bun@v1
41 |
42 | - name: 📥 Download deps
43 | run: bun install
44 |
45 | - name: 🔎 Type check
46 | run: bun run typecheck
47 |
48 | test:
49 | name: ⚡ Vitest
50 | runs-on: ubuntu-latest
51 | steps:
52 | - name: ⬇️ Checkout repo
53 | uses: actions/checkout@v4
54 |
55 | - name: ⎔ Setup node
56 | uses: actions/setup-node@v4
57 | with:
58 | node-version: '20.x'
59 |
60 | - name: 🧅 Setup bun
61 | uses: oven-sh/setup-bun@v1
62 |
63 | - name: 📥 Download deps
64 | run: bun install
65 |
66 | - name: ⚡ Run test
67 | run: bun run test -- --ci --coverage --maxWorkers=2
68 |
69 | build:
70 | name: 👷 Build
71 | runs-on: ubuntu-latest
72 | steps:
73 | - name: ⬇️ Checkout repo
74 | uses: actions/checkout@v4
75 |
76 | - name: ⎔ Setup node
77 | uses: actions/setup-node@v4
78 | with:
79 | node-version: '20.x'
80 |
81 | - name: 🧅 Setup bun
82 | uses: oven-sh/setup-bun@v1
83 |
84 | - name: 📥 Download deps
85 | run: bun install
86 |
87 | - name: 🏗️ Build
88 | run: bun run build
89 |
--------------------------------------------------------------------------------
/.github/workflows/publish.yml:
--------------------------------------------------------------------------------
1 | name: 📦 Publish
2 |
3 | on:
4 | release:
5 | types: [published]
6 |
7 | jobs:
8 | build:
9 | runs-on: ubuntu-latest
10 | steps:
11 | - name: ⬇️ Checkout repo
12 | uses: actions/checkout@v4
13 |
14 | - name: ⎔ Setup node
15 | uses: actions/setup-node@v4
16 | with:
17 | node-version: '20.x'
18 | registry-url: https://registry.npmjs.org/
19 |
20 | - name: 🧅 Setup bun
21 | uses: oven-sh/setup-bun@v1
22 |
23 | - run: bun install
24 | - run: bun run build
25 | - run: npm publish --access public
26 | env:
27 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}
28 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /node_modules
2 | /dist
3 | /coverage
4 |
5 | *.log
6 | .DS_Store
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "editor.defaultFormatter": "dbaeumer.vscode-eslint"
3 | }
4 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Contributor Covenant Code of Conduct
2 |
3 | ## Our Pledge
4 |
5 | We as members, contributors, and leaders pledge to make participation in our
6 | community a harassment-free experience for everyone, regardless of age, body
7 | size, visible or invisible disability, ethnicity, sex characteristics, gender
8 | identity and expression, level of experience, education, socio-economic status,
9 | nationality, personal appearance, race, religion, or sexual identity
10 | and orientation.
11 |
12 | We pledge to act and interact in ways that contribute to an open, welcoming,
13 | diverse, inclusive, and healthy community.
14 |
15 | ## Our Standards
16 |
17 | Examples of behavior that contributes to a positive environment for our
18 | community include:
19 |
20 | - Demonstrating empathy and kindness toward other people
21 | - Being respectful of differing opinions, viewpoints, and experiences
22 | - Giving and gracefully accepting constructive feedback
23 | - Accepting responsibility and apologizing to those affected by our mistakes,
24 | and learning from the experience
25 | - Focusing on what is best not just for us as individuals, but for the
26 | overall community
27 |
28 | Examples of unacceptable behavior include:
29 |
30 | - The use of sexualized language or imagery, and sexual attention or
31 | advances of any kind
32 | - Trolling, insulting or derogatory comments, and personal or political attacks
33 | - Public or private harassment
34 | - Publishing others' private information, such as a physical or email
35 | address, without their explicit permission
36 | - Other conduct which could reasonably be considered inappropriate in a
37 | professional setting
38 |
39 | ## Enforcement Responsibilities
40 |
41 | Community leaders are responsible for clarifying and enforcing our standards of
42 | acceptable behavior and will take appropriate and fair corrective action in
43 | response to any behavior that they deem inappropriate, threatening, offensive,
44 | or harmful.
45 |
46 | Community leaders have the right and responsibility to remove, edit, or reject
47 | comments, commits, code, wiki edits, issues, and other contributions that are
48 | not aligned to this Code of Conduct, and will communicate reasons for moderation
49 | decisions when appropriate.
50 |
51 | ## Scope
52 |
53 | This Code of Conduct applies within all community spaces, and also applies when
54 | an individual is officially representing the community in public spaces.
55 | Examples of representing our community include using an official e-mail address,
56 | posting via an official social media account, or acting as an appointed
57 | representative at an online or offline event.
58 |
59 | ## Enforcement
60 |
61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be
62 | reported to the community leaders responsible for enforcement at
63 | .
64 | All complaints will be reviewed and investigated promptly and fairly.
65 |
66 | All community leaders are obligated to respect the privacy and security of the
67 | reporter of any incident.
68 |
69 | ## Enforcement Guidelines
70 |
71 | Community leaders will follow these Community Impact Guidelines in determining
72 | the consequences for any action they deem in violation of this Code of Conduct:
73 |
74 | ### 1. Correction
75 |
76 | **Community Impact**: Use of inappropriate language or other behavior deemed
77 | unprofessional or unwelcome in the community.
78 |
79 | **Consequence**: A private, written warning from community leaders, providing
80 | clarity around the nature of the violation and an explanation of why the
81 | behavior was inappropriate. A public apology may be requested.
82 |
83 | ### 2. Warning
84 |
85 | **Community Impact**: A violation through a single incident or series
86 | of actions.
87 |
88 | **Consequence**: A warning with consequences for continued behavior. No
89 | interaction with the people involved, including unsolicited interaction with
90 | those enforcing the Code of Conduct, for a specified period of time. This
91 | includes avoiding interactions in community spaces as well as external channels
92 | like social media. Violating these terms may lead to a temporary or
93 | permanent ban.
94 |
95 | ### 3. Temporary Ban
96 |
97 | **Community Impact**: A serious violation of community standards, including
98 | sustained inappropriate behavior.
99 |
100 | **Consequence**: A temporary ban from any sort of interaction or public
101 | communication with the community for a specified period of time. No public or
102 | private interaction with the people involved, including unsolicited interaction
103 | with those enforcing the Code of Conduct, is allowed during this period.
104 | Violating these terms may lead to a permanent ban.
105 |
106 | ### 4. Permanent Ban
107 |
108 | **Community Impact**: Demonstrating a pattern of violation of community
109 | standards, including sustained inappropriate behavior, harassment of an
110 | individual, or aggression toward or disparagement of classes of individuals.
111 |
112 | **Consequence**: A permanent ban from any sort of public interaction within
113 | the community.
114 |
115 | ## Attribution
116 |
117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage],
118 | version 2.0, available at
119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
120 |
121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct
122 | enforcement ladder](https://github.com/mozilla/diversity).
123 |
124 | [homepage]: https://www.contributor-covenant.org
125 |
126 | For answers to common questions about this code of conduct, see the FAQ at
127 | https://www.contributor-covenant.org/faq. Translations are available at
128 | https://www.contributor-covenant.org/translations.
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contribution
2 |
3 | ## Setup
4 |
5 | Run `npm install` to install the dependencies.
6 |
7 | Run the tests with `npm run test`.
8 |
9 | Run the linter with `npm run lint`.
10 |
11 | Run the typechecker with `npm run typecheck`.
12 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 danestves LLC
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | 
2 |
3 |
4 | 💿 Remix Auth Clerk
5 |
6 |
7 |
12 |
13 | ```
14 | npm install remix-auth-clerk
15 | ```
16 |
17 | [](https://github.com/danestves/remix-auth-clerk/actions/workflows/main.yml)
18 | [](https://www.npmjs.com/package/remix-auth-clerk)
19 | [](https://github.com/danestves/remix-auth-clerk/blob/main/LICENSE)
20 |
21 | ## Features
22 |
23 | - **🔨 Supports multiple runtimes**: Node.js and Cloudflare Workers
24 | - **🔒 Secure** -- SOC 2 Type 2, HIPAA, Bot & Brute force detection, Password leak protection and many more thanks to Clerk
25 | - **🔌 Simple** -- easy to use and extend (_inside Clerk platform_)
26 | - **📙 Use any database** -- save the information you need with any database that you want thanks to `remix-auth`
27 | - **🚀 Remix Auth Foundation** -- an amazing authentication library for Remix.
28 |
29 |
30 | ## Supported runtimes
31 |
32 | | Runtime | Has Support |
33 | | ---------- | ----------- |
34 | | Node.js | ✅ |
35 | | Cloudflare | ✅ |
36 |
37 | > [!NOTE]
38 | > Remix Auth Clerk is only Remix v2.0+ compatible.
39 |
40 | Let's see how we can implement the Strategy into our Remix App.
41 |
42 | ## Create an OAuth application in Clerk
43 |
44 | You need to create an OAuth application in Clerk. You can do it via the [Backend API](https://clerk.com/docs/reference/backend-api/tag/OAuth-Applications) by providing a `callback_url`, a `name` and optionally the `scopes`.
45 |
46 | > [!NOTE]
47 | > In this context the name is used to help you identify your application and is not displayed anywhere publicly.
48 |
49 | ```bash
50 | curl
51 | -X POST https://api.clerk.com/v1/oauth_applications \
52 | -H "Authorization: Bearer " \
53 | -H "Content-Type: application/json" \
54 | -d {"callback_url":"https://example.com/auth/clerk/callback", "name": "remix-auth-clerk-example-app", "scopes": "profile email public_metadata"}
55 | ```
56 |
57 | Clerk will return the following:
58 |
59 | ```json
60 | {
61 | "object":"oauth_application",
62 | "id":"oa_2O4BCh3zUONvWlZHtBXv6s6tm7u",
63 | "instance_id":"ins_2O4Ak02T4fDmKKv6tK5h0WJ8ou8",
64 | "name":"remix-auth-clerk-example-app",
65 | "client_id":"d9g4CT4WYiCBm7EU",
66 | "client_secret":"VVgbT7i6sPo7sTljq2zj12fjmg0jPL5k",
67 | "scopes":"profile email public_metadata",
68 | "callback_url":"https://example.com/auth/clerk/callback",
69 | "authorize_url":"https://clerk.your-domain.com/oauth/authorize",
70 | "token_fetch_url":"https://clerk.your-domain.com/oauth/token",
71 | "user_info_url":"https://clerk.your-domain.com/oauth/userinfo",
72 | "created_at":1680809847940,
73 | "updated_at":1680810135145
74 | }
75 | ```
76 |
77 | `clerk.your-domain.com` is the domain of your Clerk instance. Save it without the `https://` part and anyting after the `.com` or `.dev` part as you will need it later.
78 |
79 | > [!WARNING]
80 | > Save the `client_id` and `client_secret` as you will need them later for security reasons.
81 |
82 | ## Session Storage
83 |
84 | We'll require to initialize a new Cookie Session Storage to work with. This Session will store user data and everything related to authentication.
85 |
86 | Create a file called `session.server.ts` wherever you want.
87 | Implement the following code and replace the `secrets` property with a strong string into your `.env` file.
88 |
89 | ```ts
90 | // app/services/auth/session.server.ts
91 | import { createCookieSessionStorage } from '@remix-run/node'
92 |
93 | export const sessionStorage = createCookieSessionStorage({
94 | cookie: {
95 | name: '__session',
96 | sameSite: 'lax',
97 | path: '/',
98 | httpOnly: true,
99 | secrets: [process.env.SESSION_SECRET || 'secret'],
100 | secure: process.env.NODE_ENV === 'production',
101 | },
102 | })
103 |
104 | export const { getSession, commitSession, destroySession } = sessionStorage
105 | ```
106 |
107 | ## Strategy Instance
108 |
109 | Now that we have everything set up, we can start implementing the Strategy Instance.
110 |
111 | Create a file called `auth.server.ts` wherever you want.
112 | Implement the following code and replace the `secret` property with a strong string into your `.env` file.
113 |
114 | ```ts
115 | // app/services/auth/config.server.ts
116 | import { Authenticator } from 'remix-auth'
117 | import { ClerkStrategy } from 'remix-auth-clerk'
118 |
119 | import { sessionStorage } from './session.server'
120 | import { db } from '~/db'
121 |
122 | // Your interface must be anything that will return on the verify callback
123 | type User = {
124 | id: string
125 | }
126 |
127 | export let authenticator = new Authenticator(sessionStorage, {
128 | throwOnError: true,
129 | })
130 |
131 | authenticator.use(
132 | new ClerkStrategy(
133 | {
134 | domain: "clerk.your-domain.com",
135 | clientID: "d9g4CT4WYiCBm7EU",
136 | clientSecret: "VVgbT7i6sPo7sTljq2zj12fjmg0jPL5k",
137 | callbackURL: "https://example.com/auth/clerk/callback",
138 | },
139 | async ({ profile, accessToken, refreshToken, extraParams, request, context }) => {
140 | // Here you can do anything you want with the user data
141 | return {
142 | id: profile.id
143 | }
144 | },
145 | ),
146 | )
147 | ```
148 |
149 | ## Auth Routes
150 |
151 | Last but not least, we'll require to create the routes that will handle the authentication flow. Create the following files inside the `app/routes` folder.
152 |
153 | ### `login.ts`
154 |
155 | ```ts
156 | /// app/routes/login.ts
157 |
158 | import { authenticator } from '~/services/auth/config.server'
159 |
160 | export async function loader({ request }: DataFunctionArgs) {
161 | return authenticator.authenticate('clerk', request, {
162 | successRedirect: '/private-routes',
163 | failureRedirect: '/',
164 | })
165 | }
166 | ```
167 |
168 | ### `logout.ts`
169 |
170 | ```ts
171 | /// app/routes/logout.ts
172 | import { type ActionFunctionArgs, redirect } from "@remix-run/node";
173 |
174 | import { authenticator } from "~/services/auth/config.server";
175 |
176 | export async function loader() {
177 | throw redirect("/");
178 | }
179 |
180 | export async function action({ request }: ActionFunctionArgs) {
181 | return authenticator.logout(request, { redirectTo: "/" });
182 | }
183 | ```
184 |
185 | ## Support
186 |
187 | If you found this library helpful, please consider leaving us a ⭐ [star](https://github.com/danestves/remix-auth-clerk). It helps the repository grow and provides the necessary motivation to continue maintaining the project.
188 |
189 | ## License
190 |
191 | Licensed under the [MIT license](https://github.com/danestves/remix-auth-clerk/blob/main/LICENSE).
192 |
--------------------------------------------------------------------------------
/bun.lockb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/danestves/remix-auth-clerk/feacc0f437c0b98bc90588eb112b0d297cb09b54/bun.lockb
--------------------------------------------------------------------------------
/config/jest.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "@jest/types";
2 | // eslint-disable-next-line unicorn/prefer-node-protocol
3 | // eslint-disable-next-line unicorn/import-style
4 | import * as path from "path";
5 |
6 | const config: Config.InitialOptions = {
7 | verbose: Boolean(process.env.CI),
8 | rootDir: path.resolve("."),
9 | collectCoverageFrom: ["/src/**/*.ts"],
10 | setupFilesAfterEnv: ["/config/jest/setup.ts"],
11 | testMatch: ["/test/**/*.test.ts"],
12 | transform: {
13 | "\\.[jt]sx?$": [
14 | "babel-jest",
15 | { configFile: "./config/jest/babel.config.js" },
16 | ],
17 | },
18 | };
19 |
20 | export default config;
21 |
--------------------------------------------------------------------------------
/config/jest/babel.config.js:
--------------------------------------------------------------------------------
1 | /**
2 | * This Babel configuration is not being used by Remix to compiler our app.
3 | * The reason to configure Babel in our project is because Jest needs this
4 | * file to support JSX and TypeScript. This is also the reason why the
5 | * preset-env targets is only the current version of Node.js
6 | */
7 | /* eslint-disable unicorn/prefer-module */
8 | module.exports = {
9 | presets: [
10 | [
11 | "@babel/preset-env",
12 | {
13 | targets: { node: "current" },
14 | },
15 | ],
16 | [
17 | "@babel/preset-react",
18 | {
19 | runtime: "automatic",
20 | },
21 | ],
22 | "@babel/preset-typescript",
23 | ],
24 | };
25 |
--------------------------------------------------------------------------------
/config/jest/setup.ts:
--------------------------------------------------------------------------------
1 | import { installGlobals } from "@remix-run/node";
2 | import "jest-fetch-mock/setupJest";
3 |
4 | installGlobals();
5 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "remix-auth-clerk",
3 | "version": "1.0.1",
4 | "keywords": [
5 | "remix",
6 | "remix-auth",
7 | "auth",
8 | "authentication",
9 | "strategy",
10 | "clerk"
11 | ],
12 | "license": "MIT",
13 | "exports": {
14 | ".": {
15 | "require": "./dist/index.js",
16 | "import": "./dist/index.mjs"
17 | }
18 | },
19 | "main": "./dist/index.js",
20 | "types": "./dist/index.d.ts",
21 | "files": [
22 | "dist",
23 | "package.json",
24 | "README.md"
25 | ],
26 | "scripts": {
27 | "build": "tsup",
28 | "coverage": "npm run test -- --coverage",
29 | "lint": "eslint --ext .ts,.tsx src/",
30 | "test": "jest --config=config/jest.config.ts --passWithNoTests",
31 | "typecheck": "tsc --project tsconfig.json --noEmit"
32 | },
33 | "dependencies": {
34 | "remix-auth-oauth2": "1.11.1"
35 | },
36 | "devDependencies": {
37 | "@babel/core": "7.23.7",
38 | "@babel/preset-env": "7.23.8",
39 | "@babel/preset-react": "7.23.3",
40 | "@babel/preset-typescript": "7.23.3",
41 | "@remix-run/node": "2.5.1",
42 | "@remix-run/react": "2.5.1",
43 | "@remix-run/server-runtime": "2.5.1",
44 | "@types/jest": "29.5.11",
45 | "@typescript-eslint/eslint-plugin": "6.19.0",
46 | "@typescript-eslint/parser": "6.19.0",
47 | "babel-jest": "29.7.0",
48 | "eslint": "8.56.0",
49 | "eslint-config-prettier": "9.1.0",
50 | "eslint-plugin-jest": "27.6.3",
51 | "eslint-plugin-jest-dom": "5.1.0",
52 | "eslint-plugin-prettier": "5.1.3",
53 | "eslint-plugin-unicorn": "50.0.1",
54 | "jest": "29.7.0",
55 | "jest-fetch-mock": "3.0.3",
56 | "prettier": "3.2.4",
57 | "react": "18.2.0",
58 | "ts-node": "10.9.2",
59 | "tsup": "8.0.1",
60 | "typescript": "5.3.3"
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import {
2 | OAuth2Profile,
3 | OAuth2Strategy,
4 | OAuth2StrategyVerifyParams,
5 | } from "remix-auth-oauth2";
6 | import type { StrategyVerifyCallback } from "remix-auth";
7 |
8 | export interface ClerkStrategyOptions {
9 | domain: string;
10 | clientID: string;
11 | clientSecret: string;
12 | callbackURL: string;
13 | scopes?: ClerkScope[] | string;
14 | }
15 |
16 | /**
17 | * @see https://clerk.com/docs/reference/backend-api/tag/OAuth-Applications#operation/CreateOAuthApplication!path=scopes&t=request
18 | */
19 | export type ClerkScope =
20 | | "profile"
21 | | "email"
22 | | "public_metadata"
23 | | "private_metadata"
24 | | string;
25 |
26 | export interface ClerkProfile extends OAuth2Profile {
27 | _json?: ClerkUserInfo;
28 | }
29 |
30 | export interface ClerkExtraParams extends Record {
31 | id_token?: string;
32 | scope: string;
33 | expires_in: number;
34 | token_type: "Bearer";
35 | }
36 |
37 | interface ClerkUserInfo {
38 | object?: "oauth_user_info";
39 | instance_id?: string;
40 | email?: string;
41 | email_verified?: boolean;
42 | family_name?: string;
43 | given_name?: string;
44 | name?: string;
45 | username?: string;
46 | picture?: string;
47 | user_id?: string;
48 | public_metadata?: Record;
49 | private_metadata?: Record;
50 | unsafe_metadata?: Record;
51 | }
52 |
53 | export const ClerkStrategyDefaultName = "clerk";
54 | export const ClerkStrategyDefaultScope: ClerkScope =
55 | "profile email public_metadata";
56 | export const ClerkStrategyScopeSeperator = " ";
57 |
58 | export class ClerkStrategy extends OAuth2Strategy<
59 | User,
60 | ClerkProfile,
61 | ClerkExtraParams
62 | > {
63 | name = ClerkStrategyDefaultName;
64 |
65 | private userInfoURL: string;
66 |
67 | constructor(
68 | options: ClerkStrategyOptions,
69 | verify: StrategyVerifyCallback<
70 | User,
71 | OAuth2StrategyVerifyParams
72 | >,
73 | ) {
74 | super(
75 | {
76 | authorizationURL: `https://${options.domain}/oauth/authorize`,
77 | tokenURL: `https://${options.domain}/oauth/token`,
78 | clientID: options.clientID,
79 | clientSecret: options.clientSecret,
80 | callbackURL: options.callbackURL,
81 | },
82 | verify,
83 | );
84 |
85 | this.userInfoURL = `https://${options.domain}/oauth/userinfo`;
86 | this.scope = this.getScope(options.scopes);
87 | }
88 |
89 | // Allow users the option to pass a scope string, or typed array
90 | private getScope(scopes: ClerkStrategyOptions["scopes"]) {
91 | if (!scopes) {
92 | return ClerkStrategyDefaultScope;
93 | } else if (typeof scopes === "string") {
94 | return scopes;
95 | }
96 |
97 | return scopes.join(ClerkStrategyScopeSeperator);
98 | }
99 |
100 | protected authorizationParams(params: URLSearchParams) {
101 | params.set("scopes", this.scope || ClerkStrategyDefaultScope);
102 |
103 | return params;
104 | }
105 |
106 | protected async userProfile(accessToken: string): Promise {
107 | let profile: ClerkProfile = {
108 | provider: ClerkStrategyDefaultName,
109 | };
110 |
111 | let response = await fetch(this.userInfoURL, {
112 | headers: { Authorization: `Bearer ${accessToken}` },
113 | });
114 | let data: ClerkUserInfo = await response.json();
115 |
116 | profile._json = data;
117 |
118 | if (data.user_id) {
119 | profile.id = data.user_id;
120 | }
121 |
122 | if (data.name) {
123 | profile.displayName = data.name;
124 | }
125 |
126 | if (data.family_name || data.given_name) {
127 | profile.name = {};
128 |
129 | if (data.family_name) {
130 | profile.name.familyName = data.family_name;
131 | }
132 |
133 | if (data.given_name) {
134 | profile.name.givenName = data.given_name;
135 | }
136 | }
137 |
138 | if (data.email) {
139 | profile.emails = [{ value: data.email }];
140 | }
141 |
142 | if (data.picture) {
143 | profile.photos = [{ value: data.picture }];
144 | }
145 |
146 | return profile;
147 | }
148 | }
149 |
--------------------------------------------------------------------------------
/test/index.test.ts:
--------------------------------------------------------------------------------
1 | import { createCookieSessionStorage } from "@remix-run/node";
2 | import { ClerkStrategy } from "../src";
3 | import { AuthenticateOptions } from "remix-auth";
4 | import fetchMock, { enableFetchMocks } from "jest-fetch-mock";
5 |
6 | enableFetchMocks();
7 |
8 | const BASE_OPTIONS: AuthenticateOptions = {
9 | name: "clerk",
10 | sessionKey: "user",
11 | sessionErrorKey: "error",
12 | sessionStrategyKey: "strategy",
13 | };
14 |
15 | describe(ClerkStrategy, () => {
16 | let verify = jest.fn();
17 | let sessionStorage = createCookieSessionStorage({
18 | cookie: { secrets: ["s3cr3t"] },
19 | });
20 |
21 | beforeEach(() => {
22 | jest.resetAllMocks();
23 | fetchMock.resetMocks();
24 | });
25 |
26 | test("should allow changing the scope", async () => {
27 | let strategy = new ClerkStrategy(
28 | {
29 | domain: "test.fake.clerk.com",
30 | clientID: "CLIENT_ID",
31 | clientSecret: "CLIENT_SECRET",
32 | callbackURL: "https://example.app/callback",
33 | scopes: "custom",
34 | },
35 | verify,
36 | );
37 |
38 | let request = new Request("https://example.app/auth/clerk");
39 |
40 | try {
41 | await strategy.authenticate(request, sessionStorage, BASE_OPTIONS);
42 | } catch (error) {
43 | if (!(error instanceof Response)) throw error;
44 | let location = error.headers.get("Location");
45 |
46 | if (!location) throw new Error("No redirect header");
47 |
48 | let redirectUrl = new URL(location);
49 |
50 | expect(redirectUrl.searchParams.get("scope")).toBe("custom");
51 | }
52 | });
53 |
54 | test("should have the scope `openid profile email` as default", async () => {
55 | let strategy = new ClerkStrategy(
56 | {
57 | domain: "test.fake.clerk.com",
58 | clientID: "CLIENT_ID",
59 | clientSecret: "CLIENT_SECRET",
60 | callbackURL: "https://example.app/callback",
61 | },
62 | verify,
63 | );
64 |
65 | let request = new Request("https://example.app/auth/clerk");
66 |
67 | try {
68 | await strategy.authenticate(request, sessionStorage, BASE_OPTIONS);
69 | } catch (error) {
70 | if (!(error instanceof Response)) throw error;
71 | let location = error.headers.get("Location");
72 |
73 | if (!location) throw new Error("No redirect header");
74 |
75 | let redirectUrl = new URL(location);
76 |
77 | expect(redirectUrl.searchParams.get("scope")).toBe(
78 | "profile email public_metadata",
79 | );
80 | }
81 | });
82 |
83 | test("should correctly format the authorization URL", async () => {
84 | let strategy = new ClerkStrategy(
85 | {
86 | domain: "test.fake.clerk.com",
87 | clientID: "CLIENT_ID",
88 | clientSecret: "CLIENT_SECRET",
89 | callbackURL: "https://example.app/callback",
90 | },
91 | verify,
92 | );
93 |
94 | let request = new Request("https://example.app/auth/clerk");
95 |
96 | try {
97 | await strategy.authenticate(request, sessionStorage, BASE_OPTIONS);
98 | } catch (error) {
99 | if (!(error instanceof Response)) throw error;
100 |
101 | let location = error.headers.get("Location");
102 |
103 | if (!location) throw new Error("No redirect header");
104 |
105 | let redirectUrl = new URL(location);
106 |
107 | expect(redirectUrl.hostname).toBe("test.fake.clerk.com");
108 | expect(redirectUrl.pathname).toBe("/oauth/authorize");
109 | }
110 | });
111 |
112 | test("should allow additional search params", async () => {
113 | let strategy = new ClerkStrategy(
114 | {
115 | domain: "test.fake.clerk.com",
116 | clientID: "CLIENT_ID",
117 | clientSecret: "CLIENT_SECRET",
118 | callbackURL: "https://example.app/callback",
119 | },
120 | verify,
121 | );
122 |
123 | let request = new Request("https://example.app/auth/clerk?test=1");
124 | try {
125 | await strategy.authenticate(request, sessionStorage, BASE_OPTIONS);
126 | } catch (error) {
127 | if (!(error instanceof Response)) throw error;
128 | let location = error.headers.get("Location");
129 |
130 | if (!location) throw new Error("No redirect header");
131 |
132 | let redirectUrl = new URL(location);
133 |
134 | expect(redirectUrl.searchParams.get("test")).toBe("1");
135 | }
136 | });
137 | });
138 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "lib": ["DOM", "DOM.Iterable", "ES2019"],
4 | "esModuleInterop": true,
5 | "moduleResolution": "Node",
6 | "module": "CommonJS",
7 | "target": "ES2019",
8 | "strict": true,
9 | "skipLibCheck": true,
10 | "declaration": true,
11 | "jsx": "react-jsx",
12 | "outDir": "./build"
13 | },
14 | "exclude": ["node_modules"],
15 | "include": ["src/**/*.ts", "src/**/*.tsx"]
16 | }
17 |
--------------------------------------------------------------------------------
/tsup.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "tsup";
2 |
3 | export default defineConfig({
4 | entry: ["src/index.ts"],
5 | treeshake: true,
6 | sourcemap: true,
7 | minify: true,
8 | clean: true,
9 | dts: true,
10 | splitting: false,
11 | format: ["cjs", "esm"],
12 | injectStyle: false,
13 | });
14 |
--------------------------------------------------------------------------------