AbsoluteJS

API Keys & M2M

Authenticate software, not people — long-lived static API keys and the OAuth2 client_credentials grant for short-lived machine-to-machine tokens. Secrets are stored only as hashes; one guard accepts either.

#Static API Keys

Mint a long-lived sk_… key bound to any owner id, with string scopes and an optional expiry. The package gives you the helpers — you wire creation behind your own admin route, exactly like SCIM tokens.

Only the hash is persisted; the plaintext is shown once, and the stored prefix lets you list keys in a UI.
TS
import { auth, createApiKey, createNeonApiKeyStore } from '@absolutejs/auth';

const apiKeyStore = createNeonApiKeyStore(process.env.DATABASE_URL);

await auth<User>({
  providersConfiguration: {},
  apikeys: { apiKeyStore }
});

// Mint a key from your own (RBAC-protected) admin route. Only the hash is
// persisted; the plaintext is returned ONCE:
const { key, record } = await createApiKey(apiKeyStore, {
  name: 'CI pipeline',
  ownerId: user.sub, // bind to a user, an org, anything — it's free-form
  scopes: ['videos:read'],
  expiresAt: Date.now() + 7776000000 // optional (90 days here)
});
// key: 'sk_8Kf3…' (show once)   record.prefix: 'sk_8Kf3…' (keep for the UI list)

#Guarding Requests

resolveApiPrincipal reads the credential from Authorization: Bearer or the X-API-Key header and routes it by prefix to the right store, returning a single ApiPrincipal (its kind, ownerId, and scopes) regardless of whether a static key or an access token was presented. hasScopes enforces AND-scope checks.

TS
import { Elysia } from 'elysia';
import { hasScopes, resolveApiPrincipal } from '@absolutejs/auth';

// One guard accepts both credential types. resolveApiPrincipal routes by token
// prefix: at_… -> access-token store, sk_… -> api-key store.
new Elysia().get('/api/videos', async ({ headers, status }) => {
  const principal = await resolveApiPrincipal({
    accessTokenStore, // client_credentials access tokens (at_)
    apiKey: headers['x-api-key'], // …and static keys via X-API-Key
    apiKeyStore, // …or via Authorization: Bearer
    authorization: headers.authorization
  });
  if (!hasScopes(principal, ['videos:read'])) {
    return status('Unauthorized', 'missing scope videos:read');
  }

  // principal.ownerId is whatever you bound the credential to at creation.
  return listVideos(principal.ownerId);
});

#Client Credentials (M2M)

For the OAuth2 client_credentials grant:

1
Register the client
Register a client — a public cid_… plus a one-time cs_… secret.
2
Trade for a token
The client trades those for a short-lived at_… access token at /oauth2/token. Credentials may arrive in the body or as an HTTP Basic header.
3
Stay revocable
Tokens are opaque and stored by hash with an expiry, so they remain revocable — no JWKS to publish.
TS
import {
  auth,
  createApiClient,
  createNeonAccessTokenStore,
  createNeonApiClientStore
} from '@absolutejs/auth';

const apiClientStore = createNeonApiClientStore(process.env.DATABASE_URL);

await auth<User>({
  providersConfiguration: {},
  apikeys: {
    accessTokenStore: createNeonAccessTokenStore(process.env.DATABASE_URL),
    apiClientStore,
    accessTokenTtlMs: 3600000 // optional; default 1 hour
  }
});
// -> mounts POST /oauth2/token (the OAuth2 client_credentials grant)

// Register a machine client from your admin route (secret returned ONCE):
const { clientId, clientSecret } = await createApiClient(apiClientStore, {
  name: 'partner-sync',
  scopes: ['videos:read', 'athletes:read']
});
// clientId: 'cid_…' (public)   clientSecret: 'cs_…' (show once)

// The client then exchanges its credentials for a short-lived access token:
//   POST /oauth2/token
//   grant_type=client_credentials&client_id=cid_…&client_secret=cs_…&scope=videos:read
//   (client_id/secret may instead be sent as an HTTP Basic header)
// -> { access_token: 'at_…', token_type: 'Bearer', expires_in: 3600, scope: 'videos:read' }
// Tokens are stored by hash + expiry, so they stay revocable (unlike a bare JWT).