better-auth-etoro
v0.1.0
Published
eToro SSO provider for better-auth — Login with eToro in ~5 lines of config
Maintainers
Readme
better-auth-etoro
eToro SSO provider for better-auth — add "Login with eToro" to any TypeScript app in ~5 lines of config.
- OAuth 2.0 + PKCE (S256) out of the box
- ID token validated via JWKS (RS256)
- Zero config beyond
clientIdandclientSecret - TypeScript-native with full type exports
- Works with Next.js, Hono, Express, SvelteKit, Remix, Astro
Install
npm install better-auth-etoroRequirements:
- Node.js >= 18
better-auth>= 1.0.0 (peer dependency)
Quick Start
Next.js App Router
// lib/auth.ts
import { betterAuth } from "better-auth";
import { genericOAuth } from "better-auth/plugins";
import { etoro } from "better-auth-etoro";
export const auth = betterAuth({
plugins: [
genericOAuth({
config: [
etoro({
clientId: process.env.ETORO_CLIENT_ID!,
clientSecret: process.env.ETORO_CLIENT_SECRET!,
}),
],
}),
],
});// lib/auth-client.ts
import { createAuthClient } from "better-auth/client";
import { genericOAuthClient } from "better-auth/client/plugins";
export const authClient = createAuthClient({
plugins: [genericOAuthClient()],
});// Sign in from any component
import { PROVIDER_ID } from "better-auth-etoro";
authClient.signIn.oauth2({ providerId: PROVIDER_ID, callbackURL: "/dashboard" });Hono
import { Hono } from "hono";
import { betterAuth } from "better-auth";
import { genericOAuth } from "better-auth/plugins";
import { etoro } from "better-auth-etoro";
const auth = betterAuth({
plugins: [
genericOAuth({
config: [
etoro({
clientId: process.env.ETORO_CLIENT_ID!,
clientSecret: process.env.ETORO_CLIENT_SECRET!,
}),
],
}),
],
});
const app = new Hono();
app.on(["GET", "POST"], "/api/auth/**", (c) => auth.handler(c.req.raw));Express
import express from "express";
import { betterAuth } from "better-auth";
import { genericOAuth } from "better-auth/plugins";
import { toNodeHandler } from "better-auth/node";
import { etoro } from "better-auth-etoro";
const auth = betterAuth({
plugins: [
genericOAuth({
config: [
etoro({
clientId: process.env.ETORO_CLIENT_ID!,
clientSecret: process.env.ETORO_CLIENT_SECRET!,
}),
],
}),
],
});
const app = express();
app.all("/api/auth/*", toNodeHandler(auth));SvelteKit
// src/lib/server/auth.ts
import { betterAuth } from "better-auth";
import { genericOAuth } from "better-auth/plugins";
import { etoro } from "better-auth-etoro";
import { ETORO_CLIENT_ID, ETORO_CLIENT_SECRET } from "$env/static/private";
export const auth = betterAuth({
plugins: [
genericOAuth({
config: [
etoro({
clientId: ETORO_CLIENT_ID,
clientSecret: ETORO_CLIENT_SECRET,
}),
],
}),
],
});// src/hooks.server.ts
import { svelteKitHandler } from "better-auth/svelte-kit";
import { auth } from "$lib/server/auth";
export async function handle({ event, resolve }) {
return svelteKitHandler({ event, resolve, auth });
}// src/lib/auth-client.ts
import { createAuthClient } from "better-auth/svelte";
import { genericOAuthClient } from "better-auth/client/plugins";
export const authClient = createAuthClient({
plugins: [genericOAuthClient()],
});Remix
// app/lib/auth.server.ts
import { betterAuth } from "better-auth";
import { genericOAuth } from "better-auth/plugins";
import { etoro } from "better-auth-etoro";
export const auth = betterAuth({
plugins: [
genericOAuth({
config: [
etoro({
clientId: process.env.ETORO_CLIENT_ID!,
clientSecret: process.env.ETORO_CLIENT_SECRET!,
}),
],
}),
],
});// app/routes/api.auth.$.ts
import { auth } from "~/lib/auth.server";
import type { LoaderFunctionArgs, ActionFunctionArgs } from "@remix-run/node";
export async function loader({ request }: LoaderFunctionArgs) {
return auth.handler(request);
}
export async function action({ request }: ActionFunctionArgs) {
return auth.handler(request);
}// app/lib/auth-client.ts
import { createAuthClient } from "better-auth/react";
import { genericOAuthClient } from "better-auth/client/plugins";
export const authClient = createAuthClient({
plugins: [genericOAuthClient()],
});Astro
// src/lib/auth.ts
import { betterAuth } from "better-auth";
import { genericOAuth } from "better-auth/plugins";
import { etoro } from "better-auth-etoro";
export const auth = betterAuth({
plugins: [
genericOAuth({
config: [
etoro({
clientId: import.meta.env.ETORO_CLIENT_ID,
clientSecret: import.meta.env.ETORO_CLIENT_SECRET,
}),
],
}),
],
});// src/pages/api/auth/[...all].ts
import { auth } from "../../../lib/auth";
import type { APIRoute } from "astro";
export const ALL: APIRoute = async (ctx) => {
return auth.handler(ctx.request);
};// src/lib/auth-client.ts
import { createAuthClient } from "better-auth/client";
import { genericOAuthClient } from "better-auth/client/plugins";
export const authClient = createAuthClient({
plugins: [genericOAuthClient()],
});Migrating from passport-etoro
If you're currently using passport-etoro with Passport.js, here's how to migrate to better-auth.
Before (passport-etoro):
import passport from "passport";
import { Strategy as EToroStrategy } from "passport-etoro";
passport.use(
new EToroStrategy(
{
clientID: process.env.ETORO_CLIENT_ID,
clientSecret: process.env.ETORO_CLIENT_SECRET,
callbackURL: "https://myapp.com/auth/etoro/callback",
},
async (accessToken, refreshToken, profile, done) => {
// Manual: verify id_token via JWKS, extract sub, find/create user
const user = await db.users.upsert({ etoroId: profile.id });
done(null, user);
},
),
);
app.get("/auth/etoro", passport.authenticate("etoro", { scope: ["openid"] }));
app.get("/auth/etoro/callback", passport.authenticate("etoro"), (req, res) => {
res.redirect("/dashboard");
});After (better-auth-etoro):
import { betterAuth } from "better-auth";
import { genericOAuth } from "better-auth/plugins";
import { etoro } from "better-auth-etoro";
export const auth = betterAuth({
plugins: [
genericOAuth({
config: [
etoro({
clientId: process.env.ETORO_CLIENT_ID!,
clientSecret: process.env.ETORO_CLIENT_SECRET!,
}),
],
}),
],
});What changed:
| passport-etoro | better-auth-etoro |
|---|---|
| clientID | clientId |
| clientSecret | clientSecret |
| callbackURL | redirectURI (optional — auto-built from baseURL) |
| Manual PKCE setup | Automatic (S256) |
| Manual JWKS validation in verify callback | Automatic via getUserInfo |
| Manual token refresh handling | Automatic via better-auth |
| passport.authenticate() middleware | authClient.signIn.oauth2({ providerId: "etoro" }) |
| passport.use() + passport.session() | Single betterAuth() config |
Multi-Provider Setup
Most apps use multiple OAuth providers. Here's eToro alongside Google in a single config:
import { betterAuth } from "better-auth";
import { genericOAuth } from "better-auth/plugins";
import { etoro } from "better-auth-etoro";
export const auth = betterAuth({
plugins: [
genericOAuth({
config: [
etoro({
clientId: process.env.ETORO_CLIENT_ID!,
clientSecret: process.env.ETORO_CLIENT_SECRET!,
}),
{
providerId: "google",
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
discoveryUrl: "https://accounts.google.com/.well-known/openid-configuration",
scopes: ["openid", "email", "profile"],
},
],
}),
],
});On the client, let the user choose a provider:
import { PROVIDER_ID } from "better-auth-etoro";
// Sign in with eToro
authClient.signIn.oauth2({ providerId: PROVIDER_ID, callbackURL: "/dashboard" });
// Sign in with Google
authClient.signIn.oauth2({ providerId: "google", callbackURL: "/dashboard" });Account linking
better-auth automatically links accounts when a user signs in with a new provider and their email matches an existing account. For example, if a user first signs in with Google ([email protected]) and later signs in with eToro using the same email, both providers are linked to the same user record. No extra configuration is needed — this works out of the box with genericOAuth.
API
PROVIDER_ID
A typed constant ("etoro") for referencing the eToro provider without hardcoded strings:
import { PROVIDER_ID } from "better-auth-etoro";
// Client-side sign-in
authClient.signIn.oauth2({ providerId: PROVIDER_ID, callbackURL: "/dashboard" });
// Conditional logic
if (session.provider === PROVIDER_ID) { /* eToro-specific logic */ }etoro(options)
Returns a config object compatible with better-auth's genericOAuth plugin.
| Option | Type | Required | Default |
|---|---|---|---|
| clientId | string | Yes | — |
| clientSecret | string | Yes | — |
| scopes | string[] | No | ["openid"] |
| redirectURI | string | No | Auto-built by better-auth |
| clockTolerance | number | No | 120 (seconds) |
| onError | (error: unknown) => void | No | — |
| endpoints | EToroEndpoints | No | Production URLs |
endpoints
| Field | Type | Default |
|---|---|---|
| authorization | string? | https://www.etoro.com/sso |
| token | string? | https://www.etoro.com/api/sso/v1/token |
| jwks | string? | https://www.etoro.com/.well-known/jwks.json |
| issuer | string? | https://www.etoro.com |
getUserInfo(tokens, clientId, options?)
Standalone function to extract a normalized user profile from an eToro id_token. Validates the token via JWKS and returns { id, name, email, image, emailVerified } or null if validation fails. Use this when you need eToro user info outside of a better-auth integration.
| Param | Type | Required | Default |
|---|---|---|---|
| tokens | { idToken?: string } | Yes | — |
| clientId | string | Yes | — |
| options.clockTolerance | number | No | 120 (seconds) |
| options.jwksUrl | string | No | https://www.etoro.com/.well-known/jwks.json |
| options.issuer | string | No | https://www.etoro.com |
| options.onError | (error: unknown) => void | No | — |
import { getUserInfo } from "better-auth-etoro";
const user = await getUserInfo(
{ idToken: rawIdToken },
"my-client-id",
);
// user = { id: "12345", name: "Jane Smith", email: "[email protected]", image: null, emailVerified: true }
// or null if validation failsWith custom options (e.g. staging environment):
const user = await getUserInfo(
{ idToken: rawIdToken },
"my-client-id",
{
clockTolerance: 300,
jwksUrl: "https://staging.etoro.com/.well-known/jwks.json",
issuer: "https://staging.etoro.com",
onError: (err) => console.error(err),
},
);Returns null (without throwing) when:
idTokenis missing or not a string- Token signature, issuer, or audience validation fails
- JWKS fetch fails
The etoro() provider delegates to this function internally, so the behavior is identical.
Debugging Silent Auth Failures
By default, getUserInfo returns null when token validation fails — no error is surfaced. Pass an onError callback to get visibility into why authentication failed:
etoro({
clientId: process.env.ETORO_CLIENT_ID!,
clientSecret: process.env.ETORO_CLIENT_SECRET!,
onError: (err) => console.error("[etoro] auth failed:", err),
})Common errors surfaced via onError:
- JWKS fetch failure (network issue reaching eToro's endpoint)
- Expired id_token (clock skew or stale token)
- Wrong issuer or audience (misconfigured
clientId) - Malformed JWT
The callback is synchronous and fire-and-forget — if it throws, the error is swallowed and getUserInfo still returns null.
Troubleshooting
Library errors
| Error Message | Cause | Fix |
|---|---|---|
| clientId is required | Empty or undefined clientId passed to etoro() | Ensure ETORO_CLIENT_ID env var is set and non-empty |
| clientSecret is required | Empty or undefined clientSecret passed to etoro() | Ensure ETORO_CLIENT_SECRET env var is set and non-empty |
| id_token is missing or empty | validateIdToken() called with an empty string | Ensure the token endpoint returned an id_token — check that openid is in your scopes |
| clientId is required for token validation | validateIdToken() called with an empty clientId | Pass the same clientId you used in etoro() |
| id_token is missing the 'sub' claim | The JWT payload doesn't contain a sub field | Contact eToro support — the id_token is malformed |
JWT validation errors (from jose)
These errors are thrown by the underlying jose library during token validation. If you're using the etoro() provider, they'll appear in your onError callback rather than being thrown directly.
| Error | Cause | Fix |
|---|---|---|
| JWSSignatureVerificationFailed | Token signature doesn't match any key in the JWKS | Verify endpoints.jwks points to the correct JWKS URL for your environment |
| JWTExpired | Token's exp claim is past now + clockTolerance | Increase clockTolerance (default: 120s), or check server clock sync |
| JWTClaimValidationFailed: unexpected "iss" claim value | Token issuer doesn't match expected issuer | If using staging, set endpoints.issuer to match your environment |
| JWTClaimValidationFailed: unexpected "aud" claim value | Token audience doesn't match your clientId | Verify clientId matches what's registered with eToro |
| ERR_JWKS_NO_MATCHING_KEY | JWKS endpoint returned keys but none match the token's kid | Likely a key rotation in progress — wait and retry, or clear your process cache |
| Network errors (e.g. fetch failed) | Cannot reach eToro's JWKS endpoint | Check network connectivity, firewalls, and DNS resolution for www.etoro.com |
getUserInfo returns null
If getUserInfo returns null, one of these scenarios applies:
idTokenis missing or not a string — The token endpoint didn't include an id_token. Ensureopenidis in your scopes (it's the default).- Token validation failed — Any of the JWT errors above will cause
null. Add anonErrorcallback to see the actual error. - JWKS fetch failed — Network issue reaching eToro's JWKS endpoint. Check connectivity.
Quick debug template:
etoro({
clientId: process.env.ETORO_CLIENT_ID!,
clientSecret: process.env.ETORO_CLIENT_SECRET!,
onError: (err) => {
console.error("[etoro] Token validation failed:");
console.error(" Error:", err instanceof Error ? err.message : err);
console.error(" Name:", err instanceof Error ? err.constructor.name : typeof err);
},
})Staging / Custom Endpoints
Override the default eToro SSO URLs to target staging or sandbox environments:
etoro({
clientId: process.env.ETORO_CLIENT_ID!,
clientSecret: process.env.ETORO_CLIENT_SECRET!,
endpoints: {
authorization: "https://staging.etoro.com/sso",
token: "https://staging.etoro.com/api/sso/v1/token",
jwks: "https://staging.etoro.com/.well-known/jwks.json",
issuer: "https://staging.etoro.com",
},
})All fields are optional — omitted fields fall back to production values. You can override just one:
etoro({
clientId: process.env.ETORO_CLIENT_ID!,
clientSecret: process.env.ETORO_CLIENT_SECRET!,
endpoints: {
jwks: "https://staging.etoro.com/.well-known/jwks.json",
},
})The issuer field controls the expected iss claim during id_token validation. If your staging environment issues tokens with a different issuer, set this to match.
validateIdToken(idToken, clientId, options?)
Standalone utility to validate an eToro id_token via JWKS. Returns the decoded EToroProfile payload or throws on invalid tokens.
| Option | Type | Required | Default |
|---|---|---|---|
| idToken | string | Yes | — |
| clientId | string | Yes | — |
| options.clockTolerance | number | No | 120 (seconds) |
| options.jwksUrl | string | No | https://www.etoro.com/.well-known/jwks.json |
| options.issuer | string | No | https://www.etoro.com |
import { validateIdToken } from "better-auth-etoro";
const profile = await validateIdToken(idToken, clientId);
// profile.sub → eToro user IDWith custom clock tolerance:
const profile = await validateIdToken(idToken, clientId, {
clockTolerance: 300, // allow up to 5 minutes of clock skew
});Testing
The package includes test utilities for writing integration tests without manually crafting JWTs or mocking JWKS endpoints.
# The testing subpath is included in the package — no extra install needed
import { createTestIdToken, createTestJWKS, resetJWKSCache, DEFAULT_TEST_CLIENT_ID } from "better-auth-etoro/testing";Setup (Vitest example)
import { beforeAll, afterEach, vi } from "vitest";
import { createTestJWKS, resetJWKSCache } from "better-auth-etoro/testing";
beforeAll(async () => {
const jwks = await createTestJWKS();
vi.stubGlobal(
"fetch",
vi.fn(() =>
Promise.resolve(new Response(JSON.stringify(jwks), {
headers: { "Content-Type": "application/json" },
})),
),
);
});
afterEach(() => {
resetJWKSCache(); // Ensures clean JWKS state between tests
});Creating test tokens
import { createTestIdToken, DEFAULT_TEST_CLIENT_ID } from "better-auth-etoro/testing";
import { validateIdToken } from "better-auth-etoro";
// Minimal token (sub defaults to "etoro-test-user")
const token = await createTestIdToken();
// With custom claims
const token = await createTestIdToken({
sub: "user-42",
email: "[email protected]",
given_name: "Test",
family_name: "User",
email_verified: true,
});
// Validate the token in your tests
const profile = await validateIdToken(token, DEFAULT_TEST_CLIENT_ID);
// profile.sub → "user-42"Test utility API
| Export | Type | Description |
|---|---|---|
| createTestIdToken(claims?, options?) | (claims?, options?) => Promise<string> | Create an RS256-signed JWT matching eToro's id_token format |
| createTestJWKS() | () => Promise<{ keys: Array<...> }> | Generate a JWKS containing the test signing key |
| resetJWKSCache() | () => void | Clear the internal JWKS cache — call in afterEach for clean test isolation |
| DEFAULT_TEST_CLIENT_ID | string | Default aud claim value for test tokens |
| DEFAULT_TEST_ISSUER | string | Default iss claim value (https://www.etoro.com) |
createTestIdToken options:
| Option | Type | Default |
|---|---|---|
| audience | string? | DEFAULT_TEST_CLIENT_ID |
| issuer | string? | https://www.etoro.com |
| expiresIn | string? | "1h" |
Types
import type {
EToroEndpoints,
EToroOptions,
EToroProfile,
EToroProviderConfig,
EToroUserInfo,
GetUserInfoOptions,
} from "better-auth-etoro";EToroUserInfo
Normalized user profile returned by getUserInfo.
| Field | Type | Description |
|---|---|---|
| id | string | eToro user ID (from sub claim) |
| name | string \| null | Full name (constructed from given_name + family_name) |
| email | string \| null | Email (if available in id_token) |
| image | null | Always null (eToro doesn't provide avatars via SSO) |
| emailVerified | boolean | true only if email_verified is explicitly true in the token |
EToroProfile
| Field | Type | Description |
|---|---|---|
| sub | string | eToro user ID |
| iss | string | Issuer (https://www.etoro.com) |
| aud | string | Audience (your clientId) |
| iat | number | Issued-at timestamp |
| exp | number | Expiry timestamp |
| given_name | string? | First name (if available) |
| family_name | string? | Last name (if available) |
| email | string? | Email (if available) |
| email_verified | boolean? | Whether the email is verified (if available) |
Token Lifetimes
| Token | Lifetime | |---|---| | Access token | ~10 minutes | | Refresh token | ~30 days | | ID token | ~10 minutes |
better-auth handles token refresh automatically via the genericOAuth plugin.
Security
- ID tokens are always validated via eToro's JWKS endpoint (RS256)
- Issuer (
iss) and audience (aud) claims are verified on every token - PKCE (S256) is enforced — no authorization code interception
- JWKS keys are fetched and cached by
josewith automatic rotation handling
Checklist
- [ ] Store
ETORO_CLIENT_IDandETORO_CLIENT_SECRETin environment variables, not in code - [ ] Use HTTPS in production (
redirectURImust be HTTPS) - [ ] Enable CSRF protection in your better-auth config
- [ ] Set
secure: trueon session cookies in production
eToro SSO Endpoints
| Endpoint | URL |
|---|---|
| Authorization | https://www.etoro.com/sso |
| Token | https://www.etoro.com/api/sso/v1/token |
| JWKS | https://www.etoro.com/.well-known/jwks.json |
| Discovery | https://www.etoro.com/.well-known/openid-configuration |
License
MIT
