Skip to content

RFC: Machine-to-Machine (M2M) Authentication #509

@lakhansamani

Description

@lakhansamani

RFC: Machine-to-Machine (M2M) Authentication

Phase: 2 — Authorization & M2M
Priority: P0 — Critical
Estimated Effort: Medium
Depends on: Rate Limiting (#501), Audit Logs (#505)


Problem Statement

Authorizer has no support for service-to-service authentication. The only authentication flow is user-interactive (password, OTP, magic link, social OAuth). There is no way for backend services, cron jobs, CI/CD pipelines, or microservices to obtain access tokens. WorkOS, Clerk, and Keycloak all support OAuth 2.0 Client Credentials grant. This is a core requirement for any auth platform used in microservice architectures.


Current Architecture Context

  • Token endpoint exists at /oauth/token but only supports authorization_code and refresh_token grant types
  • Token handler in internal/http_handlers/token.go validates client_id/client_secret against the single configured pair
  • JWT generation in internal/token/ supports HS/RS/ES algorithm families
  • Single ClientID/ClientSecret pair configured per instance — no per-application credentials
  • No Application/ServiceAccount schema exists

Proposed Solution

1. Application Schema

New schema: internal/storage/schemas/application.go

type Application struct {
    ID               string `json:"id" gorm:"primaryKey;type:char(36)"`
    Name             string `json:"name" gorm:"type:varchar(256)"`
    Description      string `json:"description" gorm:"type:text"`
    ClientID         string `json:"client_id" gorm:"type:varchar(64);uniqueIndex"`
    ClientSecretHash string `json:"-" gorm:"type:varchar(256)"`                     // bcrypt hash, never exposed
    Scopes           string `json:"scopes" gorm:"type:text"`                        // comma-separated allowed scopes
    OrganizationID   string `json:"organization_id" gorm:"type:char(36);index"`     // optional org scoping
    IsActive         bool   `json:"is_active" gorm:"type:bool;default:true"`
    CreatedBy        string `json:"created_by" gorm:"type:char(36)"`                // admin who created it
    TokenExpiresIn   int64  `json:"token_expires_in" gorm:"default:3600"`           // access token TTL in seconds
    LastUsedAt       int64  `json:"last_used_at"`
    CreatedAt        int64  `json:"created_at" gorm:"autoCreateTime"`
    UpdatedAt        int64  `json:"updated_at" gorm:"autoUpdateTime"`
}

Client ID format: app_ prefix + 32-char random hex (e.g., app_a1b2c3d4e5f6...)
Client Secret format: secret_ prefix + 48-char random hex — shown only once at creation, stored as bcrypt hash.

2. OAuth 2.0 Client Credentials Grant

Extend /oauth/token handler (internal/http_handlers/token.go):

case "client_credentials":
    // 1. Extract client credentials (Basic auth or body params)
    clientID, clientSecret := extractClientCredentials(c)
    
    // 2. Look up application
    app, err := store.GetApplicationByClientID(ctx, clientID)
    if err != nil || app == nil || !app.IsActive {
        return tokenError(c, "invalid_client", "Unknown or inactive client")
    }
    
    // 3. Verify secret
    if !bcrypt.CompareHashAndPassword(app.ClientSecretHash, clientSecret) {
        // Log failed attempt to LoginAttempt table (#501)
        return tokenError(c, "invalid_client", "Invalid client credentials")
    }
    
    // 4. Validate requested scopes against allowed scopes
    requestedScopes := parseScopes(c.PostForm("scope"))
    allowedScopes := parseScopes(app.Scopes)
    grantedScopes := intersectScopes(requestedScopes, allowedScopes)
    
    // 5. Generate access token
    claims := jwt.MapClaims{
        "sub":         app.ClientID,
        "iss":         issuer,
        "aud":         audience,
        "iat":         time.Now().Unix(),
        "exp":         time.Now().Add(time.Duration(app.TokenExpiresIn) * time.Second).Unix(),
        "scope":       strings.Join(grantedScopes, " "),
        "token_type":  "access_token",
        "client_id":   app.ClientID,
        "app_name":    app.Name,
        "grant_type":  "client_credentials",
    }
    if app.OrganizationID != "" {
        claims["org_id"] = app.OrganizationID
    }
    
    accessToken, _ := tokenProvider.SignToken(claims)
    
    // 6. Update last_used_at
    store.UpdateApplicationLastUsed(ctx, app.ID)
    
    // 7. Audit log
    auditProvider.Log(ctx, audit.AuditEvent{
        ActorID:   app.ClientID,
        ActorType: "service_account",
        Action:    "token.issued",
        Metadata:  map[string]interface{}{"grant_type": "client_credentials", "scopes": grantedScopes},
    })
    
    // 8. Return token response (RFC 6749 §4.4.3)
    c.JSON(200, gin.H{
        "access_token": accessToken,
        "token_type":   "Bearer",
        "expires_in":   app.TokenExpiresIn,
        "scope":        strings.Join(grantedScopes, " "),
    })

No refresh token for client_credentials — RFC 6749 §4.4.3 states refresh tokens SHOULD NOT be included. Clients simply request a new token when the current one expires.

Client authentication methods (RFC 6749 §2.3):

  • client_secret_basic: HTTP Basic auth with Authorization: Basic base64(client_id:client_secret)
  • client_secret_post: client_id and client_secret in POST body

3. Storage Interface Methods

AddApplication(ctx context.Context, app *schemas.Application) (*schemas.Application, error)
UpdateApplication(ctx context.Context, app *schemas.Application) (*schemas.Application, error)
DeleteApplication(ctx context.Context, id string) error
GetApplicationByID(ctx context.Context, id string) (*schemas.Application, error)
GetApplicationByClientID(ctx context.Context, clientID string) (*schemas.Application, error)
ListApplications(ctx context.Context, pagination *model.Pagination) ([]*schemas.Application, *model.Pagination, error)
UpdateApplicationLastUsed(ctx context.Context, id string) error

4. GraphQL Admin API

type Application {
    id: ID!
    name: String!
    description: String
    client_id: String!
    scopes: [String!]
    organization_id: String
    is_active: Boolean!
    created_by: String
    token_expires_in: Int64!
    last_used_at: Int64
    created_at: Int64!
}

# Only returned on create — secret shown once
type ApplicationWithSecret {
    application: Application!
    client_secret: String!          # Plaintext, shown only once
}

type Mutation {
    _create_application(params: CreateApplicationInput!): ApplicationWithSecret!
    _update_application(params: UpdateApplicationInput!): Application!
    _delete_application(id: ID!): Response!
    _rotate_application_secret(id: ID!): ApplicationWithSecret!     # Generates new secret
}

type Query {
    _applications(params: PaginatedInput): Applications!
    _application(id: ID!): Application!
}

input CreateApplicationInput {
    name: String!
    description: String
    scopes: [String!]!
    organization_id: String
    token_expires_in: Int64          # default 3600
}

input UpdateApplicationInput {
    id: ID!
    name: String
    description: String
    scopes: [String!]
    is_active: Boolean
    token_expires_in: Int64
}

5. Rate Limiting per Application

Separate rate limits for M2M clients (typically higher than user-interactive):

6. Token Validation for M2M Tokens

Downstream services validating M2M tokens can check:

{
    "sub": "app_a1b2c3d4...",
    "grant_type": "client_credentials",
    "scope": "read:users write:data",
    "client_id": "app_a1b2c3d4...",
    "app_name": "billing-service"
}

The grant_type: "client_credentials" claim distinguishes M2M tokens from user tokens. The existing validate_jwt_token query works for M2M tokens without modification.


Security Considerations

  • Client secrets are bcrypt-hashed — never stored in plaintext
  • Secrets shown only once at creation (and rotation) — cannot be retrieved later
  • _rotate_application_secret generates a new secret while keeping the same client_id
  • Old secret is immediately invalidated on rotation
  • Failed authentication attempts logged to LoginAttempt table (same as user auth)
  • Applications can be deactivated (is_active = false) without deletion

CLI Configuration Flags

--enable-m2m-auth=true                     # Enable client_credentials grant
--m2m-default-token-expiry=3600            # Default access token TTL for M2M (seconds)
--m2m-max-token-expiry=86400               # Maximum allowed token TTL (24h)
--m2m-rate-limit=1000                      # Default requests/minute per application

Migration Strategy

  1. Create applications table/collection across all DB providers
  2. Add client_credentials case to /oauth/token handler
  3. Add storage interface methods
  4. Add GraphQL admin API
  5. Existing /oauth/token behavior unchanged for authorization_code and refresh_token grants

Testing Plan

  • Integration tests for full client_credentials flow (create app → get token → validate token)
  • Test with Basic auth and POST body client authentication
  • Test scope intersection (request subset of allowed scopes)
  • Test rejected scopes (request more than allowed)
  • Test inactive application returns invalid_client
  • Test secret rotation invalidates old secret
  • Test rate limiting per application
  • Test audit logging for M2M token issuance

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions