Docsoverview

Clura Documentation

Self-hosted OAuth 2.0 / OpenID Connect identity provider. Own your auth infrastructure.

Overview

Clura is a self-hosted OAuth 2.0 / OpenID Connect identity provider. Developers register their applications on the Clura dashboard, then send their end-users to Clura's hosted login page. After authentication, Clura issues a signed id_token, access_token, and refresh_token — verifiable via Clura's public JWKS endpoint with no SDK required.

Think of it as a self-hosted Clerk or Auth0: you own the infrastructure, the keys, and the data.

No SDK required

Standard RS256 JWTs verifiable with any JWT library.

No vendor lock-in

You own the infrastructure, the keys, and the data.

Self-hostable

Deploy on any server — your rules.

How it works

Clura implements the OAuth 2.0 Authorization Code flow with Google as the identity provider.

1

Developer registers an app on the Clura dashboard

2

Developer redirects user to https://<clura>/user-login/<appClientId>

3

User authenticates with Google

4

Clura issues a short-lived authorization code (valid 2 minutes)

5

Clura redirects to your app's redirectUri with the code parameter

6

Developer exchanges the code + app_secret for tokens at /v1/global-auth/token

7

Developer verifies tokens using Clura's JWKS public key

Quickstart

Step 1 — Sign in to the Clura dashboard

Visit the Clura dashboard and sign in with Google. This creates your developer account.

Step 2 — Create an application

Click New app and enter a name and redirect URI. After creation you'll receive:

ValueDescription
appClientIdPublic identifier — safe to embed in URLs
appSecretPrivate secret — store server-side only, never expose to the browser
redirectUriThe callback URL you configured

Step 3 — Send users to Clura's login page

Redirect your users to:

http
https://<clura-host>/user-login/<appClientId>

After sign-in, Clura redirects to your redirectUri:

http
https://yourapp.com/callback?id_token=<jwt>&access_token=<jwt>&refresh_token=<opaque>

Step 4 — Handle the callback

typescript
// Node.js / Express example
app.get("/callback", (req, res) => {
  const { id_token, access_token, refresh_token } = req.query

  // Store refresh_token server-side (httpOnly cookie or encrypted session)
  // Verify id_token or access_token using the JWKS endpoint
})

Step 5 — Verify tokens

Tokens are RS256-signed JWTs. Install dependencies:

bash
npm install jwks-rsa jsonwebtoken
typescript
import jwt from "jsonwebtoken"
import jwksClient from "jwks-rsa"

const client = jwksClient({
  jwksUri: "https://<clura-host>/.well-known/jwks.json",
  cache: true,
  rateLimit: true,
})

async function verifyAccessToken(token: string) {
  const decoded = jwt.decode(token, { complete: true })
  if (!decoded || typeof decoded === "string") throw new Error("Invalid token")

  const key = await client.getSigningKey(decoded.header.kid)
  return jwt.verify(token, key.getPublicKey(), {
    algorithms: ["RS256"],
    issuer: "https://<clura-host>",
  })
}

The verified payload contains:

typescript
{
  sub: "uuid-of-user",        // stable unique user ID — use as primary key
  app_client_id: "uuid",      // your app's client ID
  sid: "uuid-of-session",     // session ID
  iss: "https://<clura-host>",
  iat: 1234567890,
  exp: 1234567890
}

Use sub as the stable, unique identifier for the user in your own database.

Protecting Routes

Express middleware

typescript
import jwt from "jsonwebtoken"
import jwksClient from "jwks-rsa"

const client = jwksClient({
  jwksUri: "https://<clura-host>/.well-known/jwks.json",
  cache: true,
})

export async function requireAuth(req, res, next) {
  const header = req.headers.authorization
  if (!header?.startsWith("Bearer ")) {
    return res.status(401).json({ message: "Missing token" })
  }
  try {
    const token = header.slice(7)
    const decoded = jwt.decode(token, { complete: true })
    const key = await client.getSigningKey(decoded.header.kid)
    req.user = jwt.verify(token, key.getPublicKey(), {
      algorithms: ["RS256"],
      issuer: "https://<clura-host>",
    })
    next()
  } catch {
    res.status(401).json({ message: "Invalid or expired token" })
  }
}

// Usage
app.get("/api/profile", requireAuth, (req, res) => {
  res.json({ userId: req.user.sub })
})

Next.js middleware

Install jose for edge-compatible JWT verification:

bash
npm install jose
typescript
// middleware.ts
import { NextRequest, NextResponse } from "next/server"
import * as jose from "jose"

const JWKS = jose.createRemoteJWKSet(
  new URL("https://<clura-host>/.well-known/jwks.json")
)

export async function middleware(req: NextRequest) {
  const token = req.cookies.get("access_token")?.value
  if (!token) return NextResponse.redirect(new URL("/login", req.url))

  try {
    await jose.jwtVerify(token, JWKS, { issuer: "https://<clura-host>" })
    return NextResponse.next()
  } catch {
    return NextResponse.redirect(new URL("/login", req.url))
  }
}

export const config = {
  matcher: ["/dashboard/:path*"],
}

Refresh Tokens

Access tokens expire after 15 minutes. Use the refresh token to obtain a new token set without re-authenticating.

The appSecret is required on every refresh request. A stolen refresh token alone is not sufficient to rotate it.

Request

http
POST https://<clura-host>/v1/global-auth/refresh
Content-Type: application/json

{
  "refresh_token": "64-char-hex-string",
  "app_client_id": "your-app-client-id",
  "app_secret": "your-app-secret"
}

Response

json
{
  "id_token": "eyJ...",
  "access_token": "eyJ...",
  "refresh_token": "new-64-char-hex"
}

Each refresh call invalidates the old token and issues a new one (rotation). Store the new refresh_token immediately.

Token Reference

Authorization Code

2 min

Format: Opaque 64-char hex string

Short-lived code exchanged for a full token set. Single-use only.

ID Token

1 hour

Format: RS256 JWT

Verify user identity once after login. Contains profile data.

Access Token

15 min

Format: RS256 JWT

Authenticate API requests. Send as Authorization: Bearer <token>.

Refresh Token

7 days

Format: Opaque 64-char hex string

Exchange for a new token set when the access token expires. Server-side only.

ID Token payload

json
{
  "sub": "uuid-of-user",
  "email": "user@example.com",
  "name": "Jane Doe",
  "picture": "https://lh3.googleusercontent.com/...",
  "app_client_id": "uuid-of-your-app",
  "sid": "uuid-of-session",
  "iss": "https://<clura-host>",
  "iat": 1234567890,
  "exp": 1234567890
}

Access Token payload

json
{
  "sub": "uuid-of-user",
  "app_client_id": "uuid-of-your-app",
  "sid": "uuid-of-session",
  "iss": "https://<clura-host>",
  "iat": 1234567890,
  "exp": 1234567890
}

API Reference

All endpoints require Authorization: Bearer <developer-token> — the token you receive after signing in to the Clura dashboard.

MethodEndpointDescription
POST/v1/appCreate a new app
GET/v1/appList all your apps
GET/v1/app/:idGet a specific app
PATCH/v1/app/:idUpdate app name or redirectUri
DELETE/v1/app/:idDelete an app
GET/v1/app/validate/:appClientIdCheck if an appClientId exists (public, no auth)

Create app

http
POST /v1/app
Authorization: Bearer <developer-token>
Content-Type: application/json

{
  "name": "My App",
  "redirectUri": "https://yourapp.com/callback"
}

Response

json
{
  "id": "uuid",
  "appClientId": "uuid",
  "appSecret": "64-char-hex",
  "name": "My App",
  "redirectUri": "https://yourapp.com/callback",
  "createdAt": "2024-01-01T00:00:00.000Z"
}

The appSecret is returned only on creation and never again.

Discovery endpoints

EndpointDescription
GET /.well-known/jwks.jsonRSA public key for token verification
GET /.well-known/openid-configurationOpenID Connect discovery document

Self-hosting

Prerequisites

  • Bun v1.3+
  • PostgreSQL database
  • Google Cloud project with OAuth 2.0 credentials

1. Clone and install

bash
git clone https://github.com/your-username/clura.git
cd clura
bun install

2. Generate RSA key pair

bash
node -e "
const { generateKeyPairSync } = require('crypto');
const { privateKey, publicKey } = generateKeyPairSync('rsa', {
  modulusLength: 2048,
  publicKeyEncoding: { type: 'spki', format: 'pem' },
  privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
});
console.log(JSON.stringify(privateKey));
console.log(JSON.stringify(publicKey));
"

3. Configure environment

Create server/.env:

bash
DATABASE_URL=postgresql://user:password@localhost:5432/clura
GOOGLE_CLIENT_ID=your_google_client_id
GOOGLE_CLIENT_SECRET=your_google_client_secret
GOOGLE_REDIRECT_URI=http://localhost:8000/v1/auth/google/callback
GLOBAL_REDIRECT_URI=http://localhost:8000/v1/global-auth/callback
JWT_SECRET=a-long-random-string
JWT_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n"
JWT_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----\n"
JWT_KEY_ID=clura-1
JWT_ISSUER=http://localhost:8000
FRONTEND_URL=http://localhost:3000

Create client/.env.local:

bash
NEXT_PUBLIC_API_URL=http://localhost:8000

4. Run migrations

bash
cd server
bunx drizzle-kit generate
bunx drizzle-kit migrate

5. Add Google redirect URIs

In Google Cloud Console → Credentials, add to Authorized redirect URIs:

http://localhost:8000/v1/auth/google/callback
http://localhost:8000/v1/global-auth/callback

6. Start

bash
bun run dev:server    # API on port 8000
bun run dev:client    # Dashboard on port 3000

Database Schema

client_tableDeveloper accounts
ColumnTypeDescription
iduuid PKDeveloper ID
google_idvarcharGoogle OAuth sub
namevarcharDisplay name
emailvarcharEmail address
avatarvarcharProfile picture URL
created_attimestampAccount creation time
app_tableRegistered applications
ColumnTypeDescription
iduuid PKInternal app ID
client_iduuid FKOwner developer
namevarcharApp display name
app_client_iduuidPublic client identifier
app_secretvarchar(64)Secret for refresh token validation
redirect_urivarchar(2048)Post-login redirect URL
created_attimestampRegistration time
user_tableEnd-users
ColumnTypeDescription
iduuid PKUser ID — the sub claim in all tokens
google_idvarcharGoogle OAuth sub
namevarcharDisplay name
emailvarcharEmail address
avatarvarcharProfile picture URL
created_attimestampFirst login time
session_tableActive sessions
ColumnTypeDescription
iduuid PKSession ID — the sid claim in tokens
user_iduuid FKEnd-user
app_client_iduuid FKApp the session belongs to
refresh_tokenvarchar(128)SHA-256 hash of the raw token
created_attimestampSession creation time
expires_attimestamp7-day expiry
auth_code_tableTemporary authorization codes
ColumnTypeDescription
iduuid PKInternal ID
codevarchar(64)Authorization code sent to client
app_client_iduuid FKApp the code belongs to
id_tokenvarcharPre-generated ID token
access_tokenvarcharPre-generated access token
refresh_tokenvarchar(64)Raw refresh token
expires_attimestamp2-minute expiry
usedbooleanWhether the code has been exchanged

Clura — self-hosted auth infrastructure