├── .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 | ![Remix Auth Clerk](https://github.com/danestves/remix-auth-clerk/assets/31737273/82cde78a-a58c-4e14-bd1a-23fcf0da78d2) 2 | 3 |

4 | 💿 Remix Auth Clerk 5 |

6 | 7 |
8 |

9 | Explore Docs » 10 |

11 |
12 | 13 | ``` 14 | npm install remix-auth-clerk 15 | ``` 16 | 17 | [![CI](https://img.shields.io/github/actions/workflow/status/danestves/remix-auth-clerk/main.yml?label=Build)](https://github.com/danestves/remix-auth-clerk/actions/workflows/main.yml) 18 | [![Release](https://img.shields.io/npm/v/remix-auth-clerk.svg?&label=Release)](https://www.npmjs.com/package/remix-auth-clerk) 19 | [![License](https://img.shields.io/badge/License-MIT-brightgreen.svg)](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 | --------------------------------------------------------------------------------