npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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 hono

Features

  • 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/me endpoint 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 app

Auth-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

  1. Sessions require consent scopes: A user must have granted scopes in their consent record to pass requireScope checks.
  2. User credentials are validated uniformly: middleware validates sessions/PATs/access-tokens with the same dispatch logic (M2M API tokens use the separate apiTokenMiddleware).
  3. Invalid credentials always 401/503: Bearer headers trigger credential verification; optionalMiddleware also rejects invalid credentials (only anonymous returns null).
  4. Token-users carry minimal data: PATs and access tokens set user = { id } only; check tokenType before accessing consent/email/name.
  5. Issuer config available: Use groo.config.issuer for 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 anonymous

Token-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 null

API Token Authentication (M2M)

API tokens allow external services (GitHub Actions, cron jobs, other backends) to authenticate with your API without user sessions.

Flow

  1. Create an API token in the Groo Accounts dashboard
  2. Give the token to your external service (e.g., GitHub Actions secret)
  3. External service sends requests with Authorization: Bearer groo_xxxx
  4. 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 app

Cloudflare Workers Configuration

// wrangler.jsonc
{
  "vars": {
    "ACCOUNTS_URL": "https://accounts.groo.dev"
  }
}

// Store secrets securely:
// wrangler secret put CLIENT_ID
// wrangler secret put CLIENT_SECRET

How It Works

Session-Based Authentication

  1. Frontend sends requests with session cookie
  2. Middleware validates session AND checks consent via accounts API
  3. If valid and consented, ConsentedUser (including appData) is added to Hono context
  4. Route handlers access user via c.get('user')

Machine-to-Machine Operations

  1. Use c.get('groo') to access the auth client in route handlers
  2. Client uses Basic auth with clientId and clientSecret
  3. Can list/get users who have consented to the application
  4. Can store and retrieve app-specific data for each user

Related Packages

License

MIT