OAuth 2.0 + OpenID Connect

Integration Guide

Add authentication to any app in minutes. Users log in through a hosted, branded page — your app just handles the callback.

1

Create App

Register in Admin Panel, get Client ID + Secret

2

Redirect

Send user to /login/{appSlug}

3

Exchange Code

POST /api/auth/token with the auth code

4

Get Profile

Call /api/auth/userinfo with access token

How It Works

Your AppIdentity ServiceBrowserUser clicks "Login"redirect → /login/{appSlug}?client_id=...User logs inredirect → /callback?code=xxx&state=xxxBrowser follows redirect to your callbackPOST /api/auth/token → access_token

1. Create an Application

Go to the Admin Panel → Applications → New Application. Fill in your app name, redirect URI, and choose which login methods to allow.

⚠️ Copy your Client Secret immediately — it is only shown once.

After creating, configure per-app settings:

  • Roles — define guest/user/admin roles; set the default signup role
  • Registration — toggle whether new users can self-register
  • Theme — logo, colors, button style, font
  • Admins — grant other users access to manage this app

2. The Hosted Login Page

Each application gets its own login page at /login/{appSlug} with full branding support.

Default branding
A

Sign in

to continue to App

Email address
Password
Sign in
Custom branding (set in Admin)
🚀

Sign in

to continue to My SaaS

Email address
Password
Sign in

3. Initiate Login (server route)

src/app/auth/login/route.ts
import { NextResponse } from 'next/server'
import { cookies } from 'next/headers'

export async function GET() {
  const state = crypto.randomUUID()
  const cookieStore = await cookies()
  cookieStore.set('auth_state', state, {
    httpOnly: true, secure: true, sameSite: 'lax', maxAge: 600,
  })

  const params = new URLSearchParams({
    client_id: process.env.AUTH_CLIENT_ID!,
    redirect_uri: process.env.AUTH_REDIRECT_URI!,
    state,
    response_type: 'code',
    scope: 'openid profile email',
  })

  return NextResponse.redirect(
    `https://auth.svc.jxs.se/login/${process.env.AUTH_APP_SLUG}?${params}`
  )
}

4. Handle the Callback

Exchange the code for tokens and store the refresh_token — use it to silently renew access tokens without re-login.

src/app/auth/callback/route.ts
const tokenRes = await fetch('https://auth.svc.jxs.se/api/auth/token', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    grant_type: 'authorization_code',
    code,
    redirect_uri: process.env.AUTH_REDIRECT_URI,
    client_id: process.env.AUTH_CLIENT_ID,
    client_secret: process.env.AUTH_CLIENT_SECRET,
  }),
})
const { access_token, id_token, refresh_token } = await tokenRes.json()

// Get user profile (includes role if configured)
const profile = await fetch('https://auth.svc.jxs.se/api/auth/userinfo', {
  headers: { Authorization: `Bearer ${access_token}` },
}).then(r => r.json())
// profile = { sub, email, name, email_verified, role? }

5. Refresh Tokens

Access tokens expire after 1 hour. Use the refresh_token to get a new one silently. Tokens are rotated on each use (old one is revoked).

const res = await fetch('https://auth.svc.jxs.se/api/auth/token', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    grant_type: 'refresh_token',
    refresh_token: storedRefreshToken,
    client_id: process.env.AUTH_CLIENT_ID,
    client_secret: process.env.AUTH_CLIENT_SECRET,
  }),
})
const { access_token, refresh_token } = await res.json()
// Store the new refresh_token — the old one is now revoked

6. Roles

If you configure roles for your app (Admin → App → Roles tab), the user's role is included in tokens automatically.

// From id_token / access_token JWT payload:
{ sub: "...", email: "...", role: "admin" }

// From userinfo endpoint:
{ sub: "...", email: "...", name: "...", role: "admin", email_verified: true }

// Read role directly from JWT (no API call needed):
const payload = JSON.parse(atob(id_token.split('.')[1]))
console.log(payload.role) // "guest" | "user" | "admin" | null

7. Hosted Change Password Page

Link your users to a hosted change-password page — no UI to build. Pass the user's current access_token and a redirect_uri to return to after success.

// Build the change-password URL in your app:
const changePasswordUrl = new URL('https://auth.svc.jxs.se/account/change-password')
changePasswordUrl.searchParams.set('token', session.access_token)
changePasswordUrl.searchParams.set('redirect_uri', 'https://yourapp.com/settings')
changePasswordUrl.searchParams.set('client_id', process.env.AUTH_CLIENT_ID)

// Link or redirect the user:
<a href={changePasswordUrl.toString()}>Change password</a>
💡 The page validates the token, shows the user's name/email, enforces password strength, and redirects back to your app on success. Store the access_token in your session alongside the refresh_token.

8. Protect Pages

src/app/dashboard/page.tsx
import { redirect } from 'next/navigation'
import { getSession } from '@/lib/session'

export default async function Dashboard() {
  const session = await getSession()
  if (!session) redirect('/auth/login')

  return (
    <div>
      <p>Welcome, {session.name}!</p>
      <p>Role: {session.role ?? 'none'}</p>
    </div>
  )
}

Environment Variables

# .env
AUTH_SERVICE_URL=https://auth.svc.jxs.se
AUTH_CLIENT_ID=your-client-id         # from admin panel
AUTH_CLIENT_SECRET=your-secret        # from admin panel (keep private!)
AUTH_REDIRECT_URI=https://yourapp.com/auth/callback
AUTH_APP_SLUG=your-app-slug           # your app's slug

SESSION_SECRET=generate-a-random-32-char-secret

Built-in Security

Rate limiting

Login endpoint blocks after 10 failed attempts per email per 15 min (Redis sliding window).

Password policy

Min 8 chars, one uppercase, one number — enforced on signup, invite, and change password.

Email verification

New signups receive a verification email. email_verified claim in tokens reflects real state.

Refresh token rotation

Each refresh_token is single-use. Revoked on use; replaces itself with a new one.

Account suspension

Admins can suspend a user per-app. Suspended users are blocked at login even with correct credentials.

Auth logs

Every login, failure, registration and token refresh is logged. Viewable in Admin per app or globally.

API Reference

GET/login/{appSlug}

Hosted login/register page. Query params: redirect_uri, client_id, state, scope, response_type.

GET/account/change-password

Hosted change-password page. Query params: token (access_token), redirect_uri, client_id.

POST/api/auth/app-login

Log in with email + password. Body: { email, password, applicationId, redirect_uri?, state? }. Rate limited to 10 attempts / 15 min.

POST/api/auth/app-register

Register new user. Body: { email, password, name, applicationId, redirect_uri?, state? }. Blocked if app.allowSignup=false.

POST/api/auth/token

Exchange auth code or refresh token. grant_type: "authorization_code" or "refresh_token". Returns: { access_token, id_token, refresh_token }.

GET/api/auth/userinfo

Get user profile. Header: Authorization: Bearer {access_token}. Returns: { sub, email, name, email_verified, role? }

POST/api/auth/account/change-password

Change password via access token. Body: { token, currentPassword, newPassword }. Used by the hosted change-password page.

GET/api/auth/verify-email

Verify email address from link. Query param: token (email verification JWT). Sets emailVerified on success.

GET/.well-known/openid-configuration

OIDC discovery document. Standard endpoint for auto-discovery of all auth endpoints.

Live Working Example

testapp.app.jxs.se is a minimal Next.js app that authenticates via this identity service.

Source at ~/workspace/test-app/ — use it as a starter template.