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.
Developer registers an app on the Clura dashboard
Developer redirects user to https://<clura>/user-login/<appClientId>
User authenticates with Google
Clura issues a short-lived authorization code (valid 2 minutes)
Clura redirects to your app's redirectUri with the code parameter
Developer exchanges the code + app_secret for tokens at /v1/global-auth/token
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:
| Value | Description |
|---|---|
| appClientId | Public identifier — safe to embed in URLs |
| appSecret | Private secret — store server-side only, never expose to the browser |
| redirectUri | The callback URL you configured |
Step 3 — Send users to Clura's login page
Redirect your users to:
https://<clura-host>/user-login/<appClientId>After sign-in, Clura redirects to your redirectUri:
https://yourapp.com/callback?id_token=<jwt>&access_token=<jwt>&refresh_token=<opaque>Step 4 — Handle the callback
// 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:
npm install jwks-rsa jsonwebtokenimport 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:
{
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
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:
npm install jose// 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
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
{
"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 minFormat: Opaque 64-char hex string
Short-lived code exchanged for a full token set. Single-use only.
ID Token
1 hourFormat: RS256 JWT
Verify user identity once after login. Contains profile data.
Access Token
15 minFormat: RS256 JWT
Authenticate API requests. Send as Authorization: Bearer <token>.
Refresh Token
7 daysFormat: Opaque 64-char hex string
Exchange for a new token set when the access token expires. Server-side only.
ID Token payload
{
"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
{
"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.
| Method | Endpoint | Description |
|---|---|---|
| POST | /v1/app | Create a new app |
| GET | /v1/app | List all your apps |
| GET | /v1/app/:id | Get a specific app |
| PATCH | /v1/app/:id | Update app name or redirectUri |
| DELETE | /v1/app/:id | Delete an app |
| GET | /v1/app/validate/:appClientId | Check if an appClientId exists (public, no auth) |
Create app
POST /v1/app
Authorization: Bearer <developer-token>
Content-Type: application/json
{
"name": "My App",
"redirectUri": "https://yourapp.com/callback"
}Response
{
"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
| Endpoint | Description |
|---|---|
| GET /.well-known/jwks.json | RSA public key for token verification |
| GET /.well-known/openid-configuration | OpenID Connect discovery document |
Self-hosting
Prerequisites
- Bun v1.3+
- PostgreSQL database
- Google Cloud project with OAuth 2.0 credentials
1. Clone and install
git clone https://github.com/your-username/clura.git
cd clura
bun install2. Generate RSA key pair
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:
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:3000Create client/.env.local:
NEXT_PUBLIC_API_URL=http://localhost:80004. Run migrations
cd server
bunx drizzle-kit generate
bunx drizzle-kit migrate5. 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/callback6. Start
bun run dev:server # API on port 8000
bun run dev:client # Dashboard on port 3000Database Schema
client_table— Developer accounts| Column | Type | Description |
|---|---|---|
| id | uuid PK | Developer ID |
| google_id | varchar | Google OAuth sub |
| name | varchar | Display name |
| varchar | Email address | |
| avatar | varchar | Profile picture URL |
| created_at | timestamp | Account creation time |
app_table— Registered applications| Column | Type | Description |
|---|---|---|
| id | uuid PK | Internal app ID |
| client_id | uuid FK | Owner developer |
| name | varchar | App display name |
| app_client_id | uuid | Public client identifier |
| app_secret | varchar(64) | Secret for refresh token validation |
| redirect_uri | varchar(2048) | Post-login redirect URL |
| created_at | timestamp | Registration time |
user_table— End-users| Column | Type | Description |
|---|---|---|
| id | uuid PK | User ID — the sub claim in all tokens |
| google_id | varchar | Google OAuth sub |
| name | varchar | Display name |
| varchar | Email address | |
| avatar | varchar | Profile picture URL |
| created_at | timestamp | First login time |
session_table— Active sessions| Column | Type | Description |
|---|---|---|
| id | uuid PK | Session ID — the sid claim in tokens |
| user_id | uuid FK | End-user |
| app_client_id | uuid FK | App the session belongs to |
| refresh_token | varchar(128) | SHA-256 hash of the raw token |
| created_at | timestamp | Session creation time |
| expires_at | timestamp | 7-day expiry |
auth_code_table— Temporary authorization codes| Column | Type | Description |
|---|---|---|
| id | uuid PK | Internal ID |
| code | varchar(64) | Authorization code sent to client |
| app_client_id | uuid FK | App the code belongs to |
| id_token | varchar | Pre-generated ID token |
| access_token | varchar | Pre-generated access token |
| refresh_token | varchar(64) | Raw refresh token |
| expires_at | timestamp | 2-minute expiry |
| used | boolean | Whether the code has been exchanged |
Clura — self-hosted auth infrastructure