-
-
Notifications
You must be signed in to change notification settings - Fork 204
Description
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/tokenbut only supportsauthorization_codeandrefresh_tokengrant types - Token handler in
internal/http_handlers/token.govalidatesclient_id/client_secretagainst the single configured pair - JWT generation in
internal/token/supports HS/RS/ES algorithm families - Single
ClientID/ClientSecretpair 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 withAuthorization: Basic base64(client_id:client_secret)client_secret_post:client_idandclient_secretin 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) error4. 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):
- Default: 1000 requests/minute per application
- Configurable per application via
rate_limitfield - Uses same rate limiting infrastructure from RFC: Rate Limiting & Brute Force Protection #501
- Rate limit key:
m2m:{client_id}
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_secretgenerates a new secret while keeping the sameclient_id- Old secret is immediately invalidated on rotation
- Failed authentication attempts logged to
LoginAttempttable (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
- Create
applicationstable/collection across all DB providers - Add
client_credentialscase to/oauth/tokenhandler - Add storage interface methods
- Add GraphQL admin API
- Existing
/oauth/tokenbehavior unchanged forauthorization_codeandrefresh_tokengrants
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