diff --git a/apps/dev/nextjs/.env.local.example b/apps/dev/nextjs/.env.local.example
index 18f1f2945f..d10b5ec99f 100644
--- a/apps/dev/nextjs/.env.local.example
+++ b/apps/dev/nextjs/.env.local.example
@@ -46,6 +46,10 @@ AUTH_KEYCLOAK_ISSUER=
AUTH_LINE_ID=
AUTH_LINE_SECRET=
+AUTH_THUNDERID_ID=
+AUTH_THUNDERID_SECRET=
+AUTH_THUNDERID_ISSUER=
+
AUTH_TRAKT_ID=
AUTH_TRAKT_SECRET=
diff --git a/docs/pages/data/manifest.json b/docs/pages/data/manifest.json
index 7fef03e029..21adc3ae0b 100644
--- a/docs/pages/data/manifest.json
+++ b/docs/pages/data/manifest.json
@@ -109,6 +109,7 @@
"slack": "Slack",
"spotify": "Spotify",
"strava": "Strava",
+ "thunderid": "ThunderID",
"tiktok": "TikTok",
"todoist": "Todoist",
"trakt": "Trakt",
@@ -154,6 +155,7 @@
"ory-hydra",
"osso",
"passage",
- "ping-id"
+ "ping-id",
+ "thunderid"
]
}
diff --git a/docs/pages/getting-started/providers/thunderid.mdx b/docs/pages/getting-started/providers/thunderid.mdx
new file mode 100644
index 0000000000..a13514ade7
--- /dev/null
+++ b/docs/pages/getting-started/providers/thunderid.mdx
@@ -0,0 +1,113 @@
+---
+title: ThunderID
+---
+
+import { Callout } from "nextra/components"
+import { Code } from "@/components/Code"
+
+
+
+# ThunderID Provider
+
+## Resources
+
+- [ThunderID Documentation](https://thunderid.dev/docs/next/)
+
+## Setup
+
+### Callback URL
+
+
+
+
+```bash
+https://example.com/api/auth/callback/thunderid
+```
+
+
+
+
+```bash
+https://example.com/auth/callback/thunderid
+```
+
+
+
+
+```bash
+https://example.com/auth/callback/thunderid
+```
+
+
+
+
+### Environment Variables
+
+```
+AUTH_THUNDERID_ID
+AUTH_THUNDERID_SECRET
+AUTH_THUNDERID_ISSUER
+```
+
+### Configuration
+
+1. Get ThunderID installed on your environment. (via `npx thunderid` or any [other option](https://thunderid.dev/docs/next/guides/getting-started/get-thunderid/))
+2. Go to the **ThunderID Console** at `https://{THUNDERID_HOST}:{THUNDERID_PORT}/console`
+3. Create an application with the **Next.js** template
+ > ⚠️ IMPORTANT: Make sure to copy the **Client Secret** at the end of the wizard since it will not be prompted again
+4. In the **General** tab, **Access** section → **Authorized redirect URIs**, add:
+ - Development: `http://localhost:3000/api/auth/callback/thunderid`
+ - Production: `https://{YOUR_DOMAIN}/api/auth/callback/thunderid`
+
+Then, add the values to your environment variables.
+
+
+
+
+```ts filename="/auth.ts"
+import NextAuth from "next-auth"
+import ThunderID from "next-auth/providers/thunderid"
+
+export const { handlers, auth, signIn, signOut } = NextAuth({
+ providers: [ThunderID],
+})
+```
+
+
+
+
+```ts filename="/src/routes/plugin@auth.ts"
+import { QwikAuth$ } from "@auth/qwik"
+import ThunderID from "@auth/qwik/providers/thunderid"
+
+export const { onRequest, useSession, useSignIn, useSignOut } = QwikAuth$(
+ () => ({
+ providers: [ThunderID],
+ })
+)
+```
+
+
+
+
+```ts filename="/src/auth.ts"
+import { SvelteKitAuth } from "@auth/sveltekit"
+import ThunderID from "@auth/sveltekit/providers/thunderid"
+
+export const { handle, signIn, signOut } = SvelteKitAuth({
+ providers: [ThunderID],
+})
+```
+
+
+
+
+```ts filename="/src/app.ts"
+import { ExpressAuth } from "@auth/express"
+import ThunderID from "@auth/express/providers/thunderid"
+
+app.use("/auth/*", ExpressAuth({ providers: [ThunderID] }))
+```
+
+
+
diff --git a/docs/public/img/providers/thunderid.svg b/docs/public/img/providers/thunderid.svg
new file mode 100644
index 0000000000..77d97e8235
--- /dev/null
+++ b/docs/public/img/providers/thunderid.svg
@@ -0,0 +1,8 @@
+
diff --git a/packages/core/src/providers/thunderid.ts b/packages/core/src/providers/thunderid.ts
new file mode 100644
index 0000000000..55ed363d1b
--- /dev/null
+++ b/packages/core/src/providers/thunderid.ts
@@ -0,0 +1,178 @@
+/**
+ *
+ *
+ * Built-in sign in with ThunderID integration.
+ *
+ *
+ *
+ *
+ *
+ *
+ * @module providers/thunderid
+ */
+
+import type { OIDCConfig, OIDCUserConfig } from "./index.js"
+
+/** The returned user profile from ThunderID when using the profile callback. */
+export interface ThunderIDProfile extends Record {
+ /** The user ThunderID account ID (subject identifier) */
+ sub: string
+
+ // Standard OIDC `profile` scope claims
+ /** The user's full name */
+ name?: string
+ /** The user's given (first) name */
+ given_name?: string
+ /** The user's family (last) name */
+ family_name?: string
+ /** The user's middle name */
+ middle_name?: string
+ /** The user's nickname */
+ nickname?: string
+ /** The user's preferred username */
+ preferred_username?: string
+ /** URL of the user's profile page */
+ profile?: string
+ /** URL of the user's profile picture */
+ picture?: string
+ /** URL of the user's website */
+ website?: string
+ /** The user's gender */
+ gender?: string
+ /** The user's birthdate (YYYY-MM-DD) */
+ birthdate?: string
+ /** The user's time zone (IANA timezone string) */
+ zoneinfo?: string
+ /** The user's locale (BCP 47 language tag) */
+ locale?: string
+ /** Unix timestamp of the last profile update */
+ updated_at?: number
+
+ // Standard OIDC `email` scope claims
+ /** The user's email address */
+ email?: string
+ /** Whether the email address has been verified */
+ email_verified?: boolean
+
+ // Standard OIDC `phone` scope claims
+ /** The user's phone number */
+ phone_number?: string
+ /** Whether the phone number has been verified */
+ phone_number_verified?: boolean
+
+ // Standard OIDC `address` scope claims
+ /** The user's postal address */
+ address?: string | Record
+
+ // ThunderID custom claims
+ /** The user type as configured in the ThunderID system */
+ userType?: string
+ /** Organization unit ID the user belongs to */
+ ouId?: string
+ /** Organization unit name */
+ ouName?: string
+ /** Organization unit handle */
+ ouHandle?: string
+ /** Roles assigned to the user (returned with the `roles` scope) */
+ roles?: string[]
+ /** Groups the user belongs to (requires explicit configuration) */
+ groups?: string[]
+}
+
+/**
+ *
+ * ### Setup
+ *
+ * #### Callback URL
+ * ```
+ * https://example.com/api/auth/callback/thunderid
+ * ```
+ *
+ * #### Configuration
+ *```ts
+ * import { Auth } from "@auth/core"
+ * import ThunderID from "@auth/core/providers/thunderid"
+ *
+ * const request = new Request(origin)
+ * const response = await Auth(request, {
+ * providers: [
+ * ThunderID({
+ * clientId: AUTH_THUNDERID_ID,
+ * clientSecret: AUTH_THUNDERID_SECRET,
+ * issuer: AUTH_THUNDERID_ISSUER,
+ * }),
+ * ],
+ * })
+ * ```
+ *
+ * ### Configuring ThunderID
+ *
+ * 1. Get ThunderID installed on your environment (via `npx thunderid` or any [other option](https://thunderid.dev/docs/next/guides/getting-started/get-thunderid/))
+ * 2. Go to the **ThunderID Console** at `https://{THUNDERID_HOST}:{THUNDERID_PORT}/console`
+ * 3. Create an application with the **Next.js** template
+ * > **Important:** Copy the **Client Secret** at the end of the wizard — it will not be shown again
+ * 4. In the **General** tab, **Access** section → **Authorized redirect URIs**, add:
+ * - Development: `http://localhost:3000/api/auth/callback/thunderid`
+ * - Production: `https://{YOUR_DOMAIN}/api/auth/callback/thunderid`
+ *
+ * Then, create a `.env.local` file in the project root and add the following entries:
+ *
+ * ```
+ * AUTH_THUNDERID_ID="Your Client ID here"
+ * AUTH_THUNDERID_SECRET="Your Client Secret here"
+ * AUTH_THUNDERID_ISSUER="Your ThunderID issuer URL here"
+ * ```
+ *
+ * ### Resources
+ *
+ * - [ThunderID Documentation](https://thunderid.dev/docs/next/)
+ * - [OAuth 2.0 / OpenID Connect Documentation](https://tools.ietf.org/html/rfc6749)
+ * - [Learn more about OAuth](https://authjs.dev/concepts/oauth)
+ *
+ * ### Notes
+ *
+ * The ThunderID provider comes with a [default configuration](#configuration). To override the defaults for your use case, check out [customizing a built-in OAuth provider](https://authjs.dev/guides/configuring-oauth-providers).
+ *
+ * :::info
+ * By default, Auth.js assumes that the ThunderID provider is based on the [OAuth 2](https://www.rfc-editor.org/rfc/rfc6749.html) and [OpenID Connect](https://openid.net/connect/) specs
+ * :::
+ *
+ * ## Help
+ *
+ * If you think you found a bug in the default configuration, you can [open an issue](https://github.com/nextauthjs/next-auth/issues/new?title=ThunderID%20Provider).
+ *
+ * Auth.js strictly adheres to the specification and it cannot take responsibility for any deviation from
+ * the spec by the provider. You can open an issue, but if the problem is non-compliance with the spec,
+ * we might not pursue a resolution. You can ask for more help in [GitHub Discussions](https://github.com/nextauthjs/next-auth/discussions).
+ */
+export default function ThunderID(
+ config: OIDCUserConfig
+): OIDCConfig {
+ const issuer = config?.issuer?.replace(/\/$/, "")
+
+ return {
+ id: "thunderid",
+ name: "ThunderID",
+ type: "oidc",
+ wellKnown: `${issuer}/.well-known/openid-configuration`,
+ style: {
+ bg: "#3688FF",
+ text: "#ffffff",
+ },
+ options: config,
+ client: { token_endpoint_auth_method: "client_secret_basic" },
+ profile(profile) {
+ return {
+ id: profile.sub,
+ name:
+ profile.name ??
+ ([profile.given_name, profile.family_name]
+ .filter(Boolean)
+ .join(" ") ||
+ null),
+ email: profile.email ?? null,
+ image: profile.picture ?? null,
+ }
+ },
+ }
+}
diff --git a/packages/core/test/providers/thunderid.test.ts b/packages/core/test/providers/thunderid.test.ts
new file mode 100644
index 0000000000..310cdb7df5
--- /dev/null
+++ b/packages/core/test/providers/thunderid.test.ts
@@ -0,0 +1,108 @@
+import { describe, expect, it } from "vitest"
+import ThunderID from "../../src/providers/thunderid"
+
+const baseConfig = {
+ clientId: "test-client-id",
+ clientSecret: "test-client-secret",
+ issuer: "https://thunderid.example.com",
+}
+
+describe("ThunderID provider", () => {
+ it("returns correct static fields", () => {
+ const config = ThunderID(baseConfig)
+ expect(config.id).toBe("thunderid")
+ expect(config.name).toBe("ThunderID")
+ expect(config.type).toBe("oidc")
+ expect(config.client).toEqual({
+ token_endpoint_auth_method: "client_secret_basic",
+ })
+ expect(config.style).toEqual({ bg: "#3688FF", text: "#ffffff" })
+ })
+
+ it("builds wellKnown URL from issuer", () => {
+ const config = ThunderID(baseConfig)
+ expect(config.wellKnown).toBe(
+ "https://thunderid.example.com/.well-known/openid-configuration"
+ )
+ })
+
+ it("strips trailing slash from issuer when building wellKnown", () => {
+ const config = ThunderID({
+ ...baseConfig,
+ issuer: "https://thunderid.example.com/",
+ })
+ expect(config.wellKnown).toBe(
+ "https://thunderid.example.com/.well-known/openid-configuration"
+ )
+ })
+
+ it("passes options through", () => {
+ const config = ThunderID(baseConfig)
+ expect(config.options).toBe(baseConfig)
+ })
+
+ describe("profile()", () => {
+ it("maps sub to id", () => {
+ const config = ThunderID(baseConfig)
+ const result = config.profile!({ sub: "user-123" }, {})
+ expect(result.id).toBe("user-123")
+ })
+
+ it("uses name when present", () => {
+ const config = ThunderID(baseConfig)
+ const result = config.profile!({ sub: "u1", name: "Alice Example" }, {})
+ expect(result.name).toBe("Alice Example")
+ })
+
+ it("falls back to given_name + family_name when name is absent", () => {
+ const config = ThunderID(baseConfig)
+ const result = config.profile!(
+ { sub: "u1", given_name: "Alice", family_name: "Example" },
+ {}
+ )
+ expect(result.name).toBe("Alice Example")
+ })
+
+ it("uses only given_name when family_name is absent", () => {
+ const config = ThunderID(baseConfig)
+ const result = config.profile!({ sub: "u1", given_name: "Alice" }, {})
+ expect(result.name).toBe("Alice")
+ })
+
+ it("returns null name when no name fields are present", () => {
+ const config = ThunderID(baseConfig)
+ const result = config.profile!({ sub: "u1" }, {})
+ expect(result.name).toBeNull()
+ })
+
+ it("maps email when present", () => {
+ const config = ThunderID(baseConfig)
+ const result = config.profile!(
+ { sub: "u1", email: "alice@example.com" },
+ {}
+ )
+ expect(result.email).toBe("alice@example.com")
+ })
+
+ it("returns null email when absent", () => {
+ const config = ThunderID(baseConfig)
+ const result = config.profile!({ sub: "u1" }, {})
+ expect(result.email).toBeNull()
+ })
+
+ it("maps picture to image when present", () => {
+ const config = ThunderID(baseConfig)
+ const result = config.profile!(
+ { sub: "u1", picture: "https://example.com/avatar.png" },
+ {}
+ )
+ expect(result.image).toBe("https://example.com/avatar.png")
+ })
+
+ it("returns null image when picture is absent", () => {
+ const config = ThunderID(baseConfig)
+ const result = config.profile!({ sub: "u1" }, {})
+ expect(result.image).toBeNull()
+ })
+ })
+})