@groo.dev/auth-server
v0.8.4
Published
Server-side authentication middleware for Groo Auth SDK
Downloads
1,036
Readme
@groo.dev/auth-server
Server-side authentication middleware and utilities for Hono and Cloudflare Workers.
Installation
npm install @groo.dev/auth-server honoFeatures
- Unified Authentication - Single initialization for middleware and M2M operations
- User Authentication Middleware - Validate session cookies and protect routes
- API Token Authentication - Verify API tokens for machine-to-machine (M2M) requests
- Token Management - Programmatically create, list, and revoke API tokens
- Auth Routes Proxy - Handle
/__auth/meendpoint for frontend SDKs - Machine-to-Machine Client - Access consented users via client credentials
- App Data - Store and retrieve app-specific data for users and tokens
Quick Start
import { Hono } from 'hono'
import { grooAuth } from '@groo.dev/auth-server'
import { GrooHonoMiddleware } from '@groo.dev/auth-server/hono'
type Env = {
CLIENT_ID: string
CLIENT_SECRET: string
}
// Create middleware with factory (env available at request time)
const hono = new GrooHonoMiddleware<Env>((env) => grooAuth({
clientId: env.CLIENT_ID,
clientSecret: env.CLIENT_SECRET,
}))
const app = new Hono<{ Bindings: Env }>()
// Initialize groo in context (required - must be first)
app.use('*', hono.init)
// Mount auth routes for frontend SDK (handles /__auth/me)
app.route('/v1', hono.routes)
// Protected route - accepts session, PAT, or access token
app.get('/v1/profile', hono.middleware, (c) => {
const user = c.get('user') // AuthUser (full profile for sessions, { id } for tokens)
return c.json({ user, tokenType: c.get('tokenType') })
})
// M2M operations - use c.get('groo')
app.get('/v1/admin/users', hono.middleware, async (c) => {
const user = c.get('user')
// Admin role lives in your app's consent appData (set via groo.setUserData).
// Gate on tokenType: PAT/access-token users carry only { id } — no consent record.
if (c.get('tokenType') !== 'session' || user?.consent?.appData?.role !== 'admin') {
return c.json({ error: 'Forbidden' }, 403)
}
const groo = c.get('groo')
const result = await groo.getUsers()
return c.json(result)
})
export default appAuth-Server v2: Unified Scope Enforcement
Overview
v2 introduces uniform scope enforcement across all credential types (sessions, access tokens, PATs) with a single middleware/requireScope pattern. Sessions now carry scopes from their consent record.
Credential Types
The unified middleware dispatcher covers three user credential types — session, PAT, and access token:
| Credential | Header Format | Example | Context | Scopes |
|------------|---------------|---------|---------|--------|
| Session | Cookie | session=abc123 | tokenType='session' | From consent.scopes[] |
| PAT | Authorization: Bearer groo_pat_... | groo_pat_abc...xyz | tokenType='pat' | From introspection |
| Access Token | Authorization: Bearer eyJ... | JWT (local JWKS verify) | tokenType='access_token' | From scope claim |
M2M API tokens (groo_...) are NOT part of the unified dispatcher. They use the separate hono.apiTokenMiddleware, which does its own token verification, sets c.get('apiToken') (not user/tokenType/scopes), and is not subject to requireScope. See API Token Authentication (M2M).
Unified Middleware Dispatch
// Single middleware validates all user credentials uniformly
app.get('/v1/profile', hono.middleware, (c) => {
const user = c.get('user') // AuthUser { id, email?, name?, consent? }
const tokenType = c.get('tokenType') // 'session' | 'access_token' | 'pat'
const scopes = c.get('scopes') // string[] (empty for token-users without scopes)
return c.json({ user, tokenType, scopes })
})Scope Enforcement
All routes requiring scopes use the same pattern regardless of credential type:
// Require drive:read scope from any credential (session/PAT/access-token)
app.get('/v1/drive/files', hono.middleware, hono.requireScope('drive:read'), (c) => {
const user = c.get('user')
return c.json({ files: [...] })
})
// Multiple scopes (AND logic - all required)
app.post('/v1/drive/files', hono.middleware, hono.requireScope('drive:read', 'drive:write'), (c) => {
// user must have BOTH scopes
return c.json({ created: true })
})
// By-method pattern (read vs. write scope)
app.route('/v1/drive')
.get(hono.middleware, hono.requireScopeByMethod('drive:read', 'drive:write'), (c) => {
return c.json({ files: [...] })
})
.post(hono.middleware, hono.requireScopeByMethod('drive:read', 'drive:write'), (c) => {
return c.json({ created: true })
})Ordering matters: requireScope/requireScopeByMethod must run after middleware (or optionalMiddleware). If placed before, or used without an auth middleware, it fails loudly with a 500 and the message requireScope must run after the auth middleware — it never silently allows the request through.
// ❌ WRONG — auth middleware missing: every request gets 500
app.get('/v1/files', hono.requireScope('drive:read'), (c) => { ... })
// ✓ RIGHT
app.get('/v1/files', hono.middleware, hono.requireScope('drive:read'), (c) => { ... })Key Rules
- Sessions require consent scopes: A user must have granted scopes in their consent record to pass
requireScopechecks. - User credentials are validated uniformly:
middlewarevalidates sessions/PATs/access-tokens with the same dispatch logic (M2M API tokens use the separateapiTokenMiddleware). - Invalid credentials always 401/503: Bearer headers trigger credential verification;
optionalMiddlewarealso rejects invalid credentials (only anonymous returns null). - Token-users carry minimal data: PATs and access tokens set
user = { id }only; checktokenTypebefore accessingconsent/email/name. - Issuer config available: Use
groo.config.issuerfor OIDC/token validation in custom code.
Context Variables (v2)
interface ContextVariableMap {
user: AuthUser | null // { id, email?, name?, consent? }
scopes: string[] // [] if no credential or token lacks scopes
tokenType: 'session' | 'access_token' | 'pat' | null // null if anonymous
apiToken: ApiTokenInfo | null // (apiTokenMiddleware only)
groo: GrooAuth // M2M client
}Example: Scope-Guarded Route
// Scope-guarded route: ALL credential types need drive:read (sessions included)
app.get('/v1/scoped-data', hono.middleware, hono.requireScope('drive:read'), (c) => {
const user = c.get('user')!
return c.json({
ok: true,
userId: user.id,
tokenType: c.get('tokenType'),
scopes: c.get('scopes')
})
})Configuration
const hono = new GrooHonoMiddleware<Env>((env) => grooAuth({
clientId: env.CLIENT_ID,
clientSecret: env.CLIENT_SECRET,
baseUrl: 'https://accounts.groo.dev', // Optional (default)
cookieName: 'session', // Optional (default)
issuer: 'https://accounts.groo.dev', // Optional: expected `iss` claim on access tokens (defaults to baseUrl)
}))Breaking Changes (v2)
Bearer Headers No Longer Ignored
Before: Malformed Authorization: Bearer headers fell through to session validation.
Now: Any Authorization: Bearer header is treated as a credential and must be valid:
groo_pat_*→ introspection required*.*.*(JWT shape) → local JWKS verification- Other → 401 Unauthorized
// This now rejects invalid tokens instead of falling back to session
// Authorization: Bearer invalid-token
// ❌ 401 Unauthorized
app.get('/v1/protected', hono.middleware, (c) => { ... })Invalid Credentials Return 401/503 (Even on optionalMiddleware)
Before: optionalMiddleware treated invalid credentials as anonymous.
Now: optionalMiddleware rejects invalid credentials (401/503); only absent credentials are treated as anonymous:
// Authorization header missing → null user (anonymous), can proceed
app.get('/v1/optional', hono.optionalMiddleware, (c) => {
const user = c.get('user') // null → ok, continue
})
// Authorization: Bearer invalid-token → 401 Unauthorized, reject
app.get('/v1/optional', hono.optionalMiddleware, (c) => {
// ❌ Returns 401; invalid credential is not treated as "anonymous"
})
// Introspection service down → 503 Service Unavailable
// ❌ 503, not fallback to anonymousToken-Users May Carry Only { id }
Before: PATs/access tokens populated user with full profile fields.
Now: PATs and access tokens set user = { id: string } only. Sessions still populate email, name, consent:
app.get('/v1/profile', hono.middleware, (c) => {
const user = c.get('user')
const tokenType = c.get('tokenType')
// Session: { id, email, name, consent }
if (tokenType === 'session') {
console.log(user.email) // ✓ Available
console.log(user.consent.appData) // ✓ Available
}
// PAT/Access Token: { id } only
if (tokenType === 'pat' || tokenType === 'access_token') {
console.log(user.email) // ❌ undefined — don't touch
console.log(user.consent) // ❌ undefined — don't touch
}
})Sessions Require Scoped Consents
Before: Sessions were valid even if consent lacked scopes.
Now: Sessions passing requireScope checks must have the granted scopes in their consent record. Users with legacy (empty-scoped) consents must re-consent via auth-react requiredScopes prop:
// User has empty consent.scopes or no scopes property
// This now fails with 403 insufficient_scope (was allowed before)
app.get('/v1/drive/files', hono.middleware, hono.requireScope('drive:read'), (c) => {
// ❌ 403 Forbidden if user.consent.scopes doesn't include 'drive:read'
})
// Frontend must request re-consent with requiredScopes:
<AuthProvider requiredScopes={['drive:read', 'drive:write']}>
{/* User logs in / re-consents with new scopes */}
</AuthProvider>API Reference
grooAuth(config)
Creates a core authentication instance for session validation and M2M operations.
import { grooAuth } from '@groo.dev/auth-server'
const groo = grooAuth({
clientId: 'your-client-id', // Required
clientSecret: 'your-client-secret', // Required
baseUrl: 'https://accounts.groo.dev', // Optional (default)
cookieName: 'session', // Optional (default)
})GrooHonoMiddleware
Creates Hono-specific middleware with a factory function for Cloudflare Workers environment.
import { GrooHonoMiddleware } from '@groo.dev/auth-server/hono'
type Env = { CLIENT_ID: string; CLIENT_SECRET: string }
const hono = new GrooHonoMiddleware<Env>((env) => grooAuth({
clientId: env.CLIENT_ID,
clientSecret: env.CLIENT_SECRET,
}))hono.init
Middleware that initializes groo in context. Must be called first with app.use('*', hono.init).
app.use('*', hono.init) // Required - enables c.get('groo')hono.middleware
Middleware that requires authentication via the unified dispatcher (session cookie, groo_pat_* PAT, or JWT access token). Returns 401 for missing or invalid credentials and 503 if the introspection service is unreachable while verifying a PAT. Sets user, scopes, and tokenType in context.
app.get('/api/protected', hono.middleware, (c) => {
const user = c.get('user') // AuthUser: full profile + consent for sessions, { id } for tokens
const scopes = c.get('scopes') // string[]
const tokenType = c.get('tokenType') // 'session' | 'access_token' | 'pat'
return c.json({ user, scopes, tokenType })
})hono.optionalMiddleware
Middleware that adds the user to context but does not require a credential. A presented-but-invalid credential is still rejected (401, or 503 on introspection outage) — only requests with no credential at all proceed as anonymous with user = null, scopes = [], tokenType = null.
app.get('/api/optional', hono.optionalMiddleware, (c) => {
const user = c.get('user') // AuthUser or null (null only when NO credential was sent)
return c.json({ authenticated: !!user })
})hono.requireScope(...scopes) / hono.requireScopeByMethod(read, write)
Scope guards that must run after middleware/optionalMiddleware. Return 403 insufficient_scope (with a WWW-Authenticate header) when the principal lacks a required scope, 401 for anonymous requests (optionalMiddleware path), and 500 if misordered (placed before/without an auth middleware). See Scope Enforcement.
hono.routes
A Hono router that handles authentication routes for frontend SDKs. Returns 401 if not authenticated.
// Mount at /v1 to handle /v1/__auth/me (requires authentication)
app.route('/v1', hono.routes)Context Variables
After hono.init is called, these are available in all routes:
c.get('groo') // GrooAuth instance for M2M operations
c.get('user') // AuthUser (after middleware/optionalMiddleware) or null
c.get('scopes') // string[] — granted scopes (after middleware/optionalMiddleware)
c.get('tokenType') // 'session' | 'access_token' | 'pat' | null
c.get('apiToken') // ApiTokenInfo (after apiTokenMiddleware) or nullAPI Token Authentication (M2M)
API tokens allow external services (GitHub Actions, cron jobs, other backends) to authenticate with your API without user sessions.
Flow
- Create an API token in the Groo Accounts dashboard
- Give the token to your external service (e.g., GitHub Actions secret)
- External service sends requests with
Authorization: Bearer groo_xxxx - Your API verifies the token via
apiTokenMiddleware
Example: Webhook Endpoint
// Protected by API token (not user session)
app.post('/v1/webhook', hono.apiTokenMiddleware, (c) => {
const token = c.get('apiToken')
console.log(`Request from: ${token.application_name} / ${token.token_name}`)
return c.json({ received: true })
})
// GitHub Action can call this with:
// curl -X POST https://api.myapp.com/v1/webhook \
// -H "Authorization: Bearer groo_xxxx"hono.apiTokenMiddleware
Middleware that requires a valid API token. Returns 401 if token is missing or invalid.
app.post('/v1/internal/sync', hono.apiTokenMiddleware, (c) => {
const token = c.get('apiToken') // ApiTokenInfo
return c.json({
application: token.application_name,
token: token.token_name,
})
})ApiTokenInfo Object
interface ApiTokenInfo {
active: true
client_id: string
token_type: 'Bearer'
exp?: number // Expiration timestamp (if set)
iat: number // Issued at timestamp
sub: string // Token ID
aud: string // Client ID
application_id: string
application_name: string
token_name: string
token_description: string | null
app_data: Record<string, unknown> // Token-specific custom data
}
interface ApiToken<T = Record<string, unknown>> {
id: string
applicationId: string
name: string
description: string | null
tokenPrefix: string // Last 4 chars (e.g., "a1b2")
createdBy: string | null // User ID if created from dashboard, null if M2M
lastUsed: string | null
expiresAt: string | null
expired: boolean
revoked: boolean
appData: T
createdAt: string
}Using Token App Data
Each token can store custom metadata (e.g., rate limits, permissions, environment):
app.post('/v1/webhook', hono.apiTokenMiddleware, (c) => {
const token = c.get('apiToken')
// Access token-specific configuration
const rateLimit = token.app_data.rate_limit as number || 1000
const environment = token.app_data.environment as string || 'production'
console.log(`Token ${token.token_name}: rate_limit=${rateLimit}, env=${environment}`)
return c.json({ received: true })
})Direct Token Verification
You can also verify tokens manually without middleware:
app.post('/v1/custom', async (c) => {
const groo = c.get('groo')
const authHeader = c.req.header('Authorization')
if (authHeader?.startsWith('Bearer ')) {
const token = authHeader.slice(7)
const tokenInfo = await groo.verifyApiToken(token)
if (tokenInfo) {
// Token is valid
return c.json({ valid: true, app: tokenInfo.application_name })
}
}
return c.json({ error: 'Unauthorized' }, 401)
})M2M Operations (via c.get('groo'))
app.get('/v1/admin/users', hono.middleware, async (c) => {
const groo = c.get('groo')
// List all consented users
const { users, total } = await groo.getUsers({ page: 1, perPage: 20 })
// Get specific user by ID
const user = await groo.getUser('user-id')
// Get user by email
const userByEmail = await groo.getUserByEmail('[email protected]')
// Get user by phone
const userByPhone = await groo.getUserByPhone('+1234567890')
// Get/set app data
const data = await groo.getUserData('user-id')
await groo.setUserData('user-id', { plan: 'premium' })
})Token Management
Programmatically manage API tokens for your application. All token methods support generic types for type-safe appData:
const groo = grooAuth({
clientId: env.CLIENT_ID,
clientSecret: env.CLIENT_SECRET,
})
// Define your token data type
interface TokenData {
environment: 'production' | 'staging'
permissions: string[]
}
// List all tokens (with typed appData)
const tokens = await groo.getTokens<TokenData>()
// Create a new token (with typed appData)
const { token, secret } = await groo.createToken<TokenData>({
name: 'CI/CD Pipeline',
description: 'For automated deployments',
expiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000), // 90 days
appData: { environment: 'production', permissions: ['deploy'] },
})
console.log('Save this secret:', secret) // Only shown once!
// Get a specific token (with typed appData)
const tokenInfo = await groo.getToken<TokenData>(token.id)
// Update token app data (type-safe)
await groo.setTokenData<TokenData>(token.id, {
environment: 'staging',
permissions: ['deploy', 'rollback'],
})
// Get token app data (typed)
const data = await groo.getTokenData<TokenData>(token.id)
// data.environment is typed as 'production' | 'staging'
// Revoke a token
await groo.revokeToken(token.id)Token Management Methods
| Method | Description |
|--------|-------------|
| getTokens() | List all API tokens for the application |
| getToken(tokenId) | Get a specific token by ID |
| createToken(options) | Create a new API token (returns secret once!) |
| revokeToken(tokenId) | Revoke/delete a token |
| getTokenData(tokenId) | Get token-specific app data |
| setTokenData(tokenId, data) | Set token-specific app data |
CreateTokenOptions
interface CreateTokenOptions {
name: string // Required: token name
description?: string // Optional: description
expiresAt?: string | Date // Optional: expiration date
appData?: Record<string, unknown> // Optional: custom data
}User Object
c.get('user') is typed as AuthUser | null. For sessions the value is the full ConsentedUser; for PATs and access tokens it carries only { id } — check c.get('tokenType') before reading any other field:
// Context type set by middleware/optionalMiddleware
interface AuthUser {
id: string
email?: string | null
name?: string | null
consent?: UserConsent // Present for sessions only
}
// M2M types (returned by groo.getUsers()/getUser(); also the runtime shape of session users)
interface User {
id: string
email: string | null
phone: string | null
name: string | null
role: string
}
interface ConsentedUser extends User {
consent: {
id: string
userId: string
applicationId: string
consentedAt: string
lastAccessedAt: string
revokedAt: string | null
appData: Record<string, unknown> // App-specific user data
scopes?: string[] // Granted OAuth scopes (v2)
}
}Full Example
// src/index.ts
import { Hono } from 'hono'
import { grooAuth } from '@groo.dev/auth-server'
import { GrooHonoMiddleware } from '@groo.dev/auth-server/hono'
type Env = {
CLIENT_ID: string
CLIENT_SECRET: string
ACCOUNTS_URL: string
}
const hono = new GrooHonoMiddleware<Env>((env) => grooAuth({
clientId: env.CLIENT_ID,
clientSecret: env.CLIENT_SECRET,
baseUrl: env.ACCOUNTS_URL,
}))
const app = new Hono<{ Bindings: Env }>()
// Initialize groo in context
app.use('*', hono.init)
// Mount auth routes for frontend SDK
app.route('/v1', hono.routes)
// Public endpoint
app.get('/v1/health', (c) => {
return c.json({ status: 'ok' })
})
// Protected endpoint (session, PAT, or access token)
app.get('/v1/profile', hono.middleware, (c) => {
const user = c.get('user')
return c.json({ user, tokenType: c.get('tokenType') })
})
// Scope-guarded endpoint - all credential types need drive:read
app.get('/v1/files', hono.middleware, hono.requireScope('drive:read'), (c) => {
return c.json({ files: [] })
})
// Admin endpoint - list all consented users
// Admin role lives in this app's consent appData (set via groo.setUserData);
// token credentials carry only { id }, so gate on tokenType first.
app.get('/v1/admin/users', hono.middleware, async (c) => {
const user = c.get('user')
if (c.get('tokenType') !== 'session' || user?.consent?.appData?.role !== 'admin') {
return c.json({ error: 'Forbidden' }, 403)
}
const groo = c.get('groo')
const result = await groo.getUsers()
return c.json(result)
})
export default appCloudflare Workers Configuration
// wrangler.jsonc
{
"vars": {
"ACCOUNTS_URL": "https://accounts.groo.dev"
}
}
// Store secrets securely:
// wrangler secret put CLIENT_ID
// wrangler secret put CLIENT_SECRETHow It Works
Session-Based Authentication
- Frontend sends requests with session cookie
- Middleware validates session AND checks consent via accounts API
- If valid and consented,
ConsentedUser(includingappData) is added to Hono context - Route handlers access user via
c.get('user')
Machine-to-Machine Operations
- Use
c.get('groo')to access the auth client in route handlers - Client uses Basic auth with
clientIdandclientSecret - Can list/get users who have consented to the application
- Can store and retrieve app-specific data for each user
Related Packages
@groo.dev/auth-react- React hooks and components@groo.dev/auth-core- Shared types and utilities
License
MIT
