LunarWerx Auth

Wire up a new bot

Four steps. Only the first needs Discord's web portal, and it is the same paste every time.

1. Register the redirect on your Discord application

Discord Developer Portal → your application → OAuth2 → Redirects → Add Redirect, then Save. This exact string, with no trailing slash:

https://auth.lunarwerx.com/callback

This is the only manual step, and it cannot be automated: Discord's API accepts a PATCH to redirect_uris and silently ignores it. Verified the hard way.

2. Register the application here

Once. The client secret is encrypted at rest and is never returned by any endpoint, at any permission level.

curl -X POST https://auth.lunarwerx.com/v1/apps \
  -H "Authorization: Bearer $LUNARWERX_AUTH_ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "client_id": "1234567890123456789",
    "slug": "your-bot",
    "name": "Your Bot",
    "client_secret": "…",
    "scopes": ["identify", "guilds"],
    "origins": []
  }'

origins is for hosts outside lunarwerx.com. Anything under lunarwerx.com is trusted automatically, so a bot living at your-bot.lunarwerx.com needs nothing here. A white-label customer serving from their own domain gets their exact origin added to this list, and nothing else will be handed a session.

3. Send people here to sign in

Generate a PKCE verifier, keep it in a short-lived cookie, and redirect. The origin you pass is where the finished login is handed back.

const verifier = base64url(crypto.getRandomValues(new Uint8Array(32)));
const challenge = base64url(await crypto.subtle.digest('SHA-256', encode(verifier)));

const url = new URL('https://auth.lunarwerx.com/authorize');
url.searchParams.set('app', 'your-bot');
url.searchParams.set('origin', 'https://your-bot.lunarwerx.com');
url.searchParams.set('return', '/api/auth/return');   // default
url.searchParams.set('next', '/dashboard');           // where to land afterwards
url.searchParams.set('code_challenge', challenge);
url.searchParams.set('code_challenge_method', 'S256');

// Store `verifier` in an HttpOnly cookie, then 302 to url.

4. Redeem the code, verify the token

// GET /api/auth/return?code=…&next=…
const res = await fetch('https://auth.lunarwerx.com/token', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ code, code_verifier: verifierFromCookie, client_id: CLIENT_ID }),
});
const { id_token, user, discord } = await res.json();

// Verify against the published key set before trusting a single claim.
const claims = await verifyIdentityToken(id_token, { issuer: 'https://auth.lunarwerx.com', audience: CLIENT_ID });
// claims.sub is the Discord user id. discord.access_token is theirs to use.

The drop-in client at services/auth/client/lunarwerx-auth.ts does all of the above, including the key-set fetch and cache. Copy the file; it has no dependencies.

What you get back

{
  "id_token": "eyJhbGciOiJFUzI1NiIs…",     ES256, verify against /.well-known/jwks.json
  "expires_in": 604800,
  "user":    { "id", "name", "username", "avatarUrl" },
  "discord": { "access_token", "refresh_token", "expires_in" },
  "scope":   "identify guilds"
}

The Discord tokens are in the body, once, to your server. They are deliberately NOT in the identity token: whatever you do with the JWT, usually a cookie, would be where they ended up too.

When something is refused

Every refusal names a reason. The ones you will actually meet:

origin_not_allowed        the origin is not under lunarwerx.com and is not on the app
unknown_application       no app registered under that slug or client id
invalid_state             the flow did not start here, or started in another browser
state_expired             more than 15 minutes between starting and finishing
invalid_code              already redeemed, or never existed. Codes are single-use
expired_code              more than 2 minutes between callback and redemption
invalid_verifier          the PKCE verifier does not match the challenge
client_mismatch           that code was minted for a different application

Rotating keys

Rotation is additive. The old key keeps appearing in the key set until every token it signed has expired, so nobody is logged out.

curl -X POST https://auth.lunarwerx.com/v1/keys -H "Authorization: Bearer $LUNARWERX_AUTH_ADMIN_TOKEN"