OAuth 2.0: Building Secure M2M Foundations
Parent Epic: #137 Dependencies: None (this is the foundation) Blocked By: Nothing Blocks: Phase 2, Phase 3
Overview
Hey there, devs! Let's dive into Phase 1: Foundation (M2M & Core Security), the absolute bedrock for our OAuth 2.0 implementation. This phase is all about setting up a super secure, non-interactive foundation, specifically for our machine-to-machine (M2M) clients. Think of it as building the engine room before we even think about the ship's deck. It covers all the essential prerequisites for generating and, crucially, validating OAuth tokens. This isn't just any part of the project; it's the critical foundation that every other phase will lean on. Without this solid base, nothing else can stand. Our main goal here is straightforward but mighty: Enable API Consumers to authenticate via Client Credentials grant and receive JWT access tokens that can be used to access protected API endpoints. This means our backend services, our M2M clients, can confidently and securely talk to our APIs without needing a human to log in. It’s all about automation and security working hand-in-hand. So, buckle up, guys, because we're laying the groundwork for some serious API power!
Database Schema
Alright, let's get our hands dirty with the database schema. This is where the magic starts, folks. We need robust tables to manage our API consumers (think of them as your API clients) and their permissions, along with a place to keep track of those all-important access tokens.
Table: api_consumers (OAuth Clients)
This table is the heart of our client management. Each row represents an application or service that wants to access our APIs.
CREATE TABLE api_consumers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
-- Client credentials
client_id VARCHAR(64) NOT NULL UNIQUE,
client_secret_hash VARCHAR NOT NULL,
client_secret_prefix VARCHAR(12) NOT NULL, -- For UI display: "cust_xxxxx..."
-- Secret rotation grace period (allows zero-downtime rotation)
previous_secret_hash VARCHAR, -- Previous secret (valid during grace period)
secret_rotated_at TIMESTAMP WITH TIME ZONE, -- When current secret was set
-- Metadata
name VARCHAR(100) NOT NULL,
description TEXT,
logo_url VARCHAR,
-- OAuth configuration
redirect_uris TEXT[] NOT NULL DEFAULT '{}',
allowed_grant_types TEXT[] NOT NULL DEFAULT ARRAY['client_credentials'],
allowed_scopes TEXT[] NOT NULL DEFAULT ARRAY['dataset:read'],
-- Client type (determines security requirements)
client_type VARCHAR NOT NULL CHECK (client_type IN ('public', 'confidential')),
-- public: Mobile/SPA apps (cannot securely store secrets, PKCE required)
-- confidential: Backend services (can store secrets securely)
-- Status
is_active BOOLEAN DEFAULT true,
-- First-party client flag (skip consent for trusted apps owned by same user)
skip_consent BOOLEAN DEFAULT false,
-- Timestamps
inserted_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_api_consumers_owner ON api_consumers(owner_id);
CREATE INDEX idx_api_consumers_client_id ON api_consumers(client_id);
We've got columns for unique identifiers like client_id, and crucially, a client_secret_hash because we never store secrets in plaintext, guys. We're using Argon2id, just like for user passwords, for hashing. The client_secret_prefix is a neat little trick for showing a snippet in the UI without revealing anything sensitive. The secret_rotated_at and previous_secret_hash columns are key for enabling zero-downtime secret rotation – super important for production systems. We've also got metadata like name and description, OAuth config like allowed_grant_types (defaulting to client_credentials for this phase) and allowed_scopes, and the client_type which distinguishes between 'public' apps (like mobile or SPAs that can't keep secrets safe) and 'confidential' apps (backend services that can). An is_active flag lets us easily disable clients, and skip_consent is handy for first-party apps. Indexes on owner_id and client_id are vital for performance.
Table: api_consumer_dataset_grants (Client-to-Dataset Permissions)
This table is where we define exactly what data each API consumer can access. It links an api_consumer to a specific api (dataset) and defines the permissions, like 'read', 'write', or 'delete'.
CREATE TABLE api_consumer_dataset_grants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
api_consumer_id UUID NOT NULL REFERENCES api_consumers(id) ON DELETE CASCADE,
api_id UUID NOT NULL REFERENCES apis(id) ON DELETE CASCADE,
-- Permissions for this specific dataset
scopes TEXT[] NOT NULL DEFAULT ARRAY['read'], -- read, write, delete
-- Optional row-level filtering (Phase 4)
filter_rules JSONB DEFAULT '{}',
-- Timestamps
inserted_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE(api_consumer_id, api_id)
);
-- CRITICAL: Multi-tenant isolation trigger
-- Ensures api_consumer and api belong to the same owner
CREATE OR REPLACE FUNCTION check_same_owner_grant()
RETURNS TRIGGER AS $
BEGIN
IF (SELECT owner_id FROM api_consumers WHERE id = NEW.api_consumer_id) !=
(SELECT user_id FROM apis WHERE id = NEW.api_id) THEN
RAISE EXCEPTION 'Cross-tenant access denied: consumer and API must belong to same owner';
END IF;
RETURN NEW;
END;
$ LANGUAGE plpgsql;
CREATE TRIGGER enforce_same_owner_grant
BEFORE INSERT OR UPDATE ON api_consumer_dataset_grants
FOR EACH ROW EXECUTE FUNCTION check_same_owner_grant();
CREATE INDEX idx_consumer_grants_consumer ON api_consumer_dataset_grants(api_consumer_id);
CREATE INDEX idx_consumer_grants_api ON api_consumer_dataset_grants(api_id);
The real star here is the multi-tenant isolation trigger. This is a non-negotiable security feature, guys. It ensures that an API consumer owned by User A cannot be granted access to an API owned by User B. It’s a database-level guardrail that prevents accidental or malicious cross-tenant data access. We're also defining the scopes here and leaving room for filter_rules in a later phase. The UNIQUE(api_consumer_id, api_id) constraint prevents duplicate grant entries.
Table: oauth_access_tokens
This table stores information about issued access tokens. While JWTs are largely self-contained, we need this table for revocation purposes and for tracking token usage.
CREATE TABLE oauth_access_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
jti VARCHAR(64) NOT NULL UNIQUE, -- JWT ID for revocation
api_consumer_id UUID NOT NULL REFERENCES api_consumers(id) ON DELETE CASCADE,
user_id UUID REFERENCES users(id) ON DELETE CASCADE, -- NULL for client_credentials
scopes TEXT[] NOT NULL,
-- Lifecycle
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
revoked_at TIMESTAMP WITH TIME ZONE,
inserted_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_access_tokens_jti ON oauth_access_tokens(jti);
CREATE INDEX idx_access_tokens_consumer ON oauth_access_tokens(api_consumer_id);
CREATE INDEX idx_access_tokens_user ON oauth_access_tokens(user_id);
The jti (JWT ID) is crucial for checking if a token has been revoked. We link tokens back to the api_consumer (and potentially a user_id for user-associated tokens in later phases), store the granted scopes, and track the expires_at time. The revoked_at timestamp is key for our revocation mechanism. Indexing jti, api_consumer_id, and user_id will help speed up lookups.
Implementation Tasks
Now for the nitty-gritty: the implementation tasks. This is where we turn our schema designs into working code. Get ready, because there’s a fair bit to do!
1. Database Migrations
First things first, we need to bring our database schema to life. This involves creating the actual SQL migration files.
- [ ] Create migration for
api_consumerstable with all columns and indexes. - [ ] Create migration for
api_consumer_dataset_grantstable with multi-tenant trigger. - [ ] Create migration for
oauth_access_tokenstable.
This is the foundation of our foundation, ensuring our database is ready to handle all the OAuth interactions we're about to build.
2. OAuth Context Module
We need a central place to manage all our OAuth-related logic. This is where the CsvJsonApi.OAuth context module comes in. It'll be the main entry point for interacting with our OAuth features.
- [ ] Create
CsvJsonApi.OAuthcontext module as the main entry point. - [ ] Create
CsvJsonApi.OAuth.ApiConsumerschema with changeset validations. This is where we’ll define the structure and validation rules for our API consumers. - [ ] Create
CsvJsonApi.OAuth.ApiConsumerGrantschema. Similar to the above, but for the dataset grants. - [ ] Create
CsvJsonApi.OAuth.AccessTokenschema. This will handle the lifecycle and data for our access tokens.
3. Client Credential Generation
Generating secure and unique credentials for our API consumers is paramount.
- [ ] Implement
client_idgeneration: It needs to be 32 hexadecimal characters and, critically, globally unique across all consumers. We'll likely use a combination of random generation and database checks to ensure uniqueness. - [ ] Implement
client_secretgeneration: This should be 32 bytes (256 bits) of entropy, encoded in Base64. We need strong randomness here. - [ ] Hash secrets with Argon2id: Just like user passwords, client secrets must be hashed. We’ll use Argon2id with the same parameters for consistency and security.
- [ ] Store prefix (
cust_xxxx...) for UI display purposes: We don't want to show the whole secret, just a snippet to help users identify it. - [ ] CRITICAL: Secret is only returned once at creation time, never stored in plaintext. This is a fundamental security rule. Once generated and handed to the user, it's gone from our system's direct view. The hash is all we store.
4. API Consumer CRUD Operations
We need standard Create, Read, Update, Delete (CRUD) operations for managing API consumers.
Create Consumer: This function will take the owner and a map of attributes, generate the secret, hash it, and return the consumer along with the plaintext secret (which the user must save).
OAuth.create_api_consumer(owner, %{
name: "Partner App",
description: "Integration for partner",
client_type: "confidential",
allowed_grant_types: ["client_credentials"],
allowed_scopes: ["dataset:read"]
})
# Returns: {:ok, %{consumer: consumer, client_secret: "cust_..."}}
List Consumers: This should return only the consumers owned by the requesting user, ensuring data isolation.
OAuth.list_api_consumers(owner)
# Returns consumers owned by this user only
Update Consumer: Allows modification of consumer settings like name, description, or status.
OAuth.update_api_consumer(consumer, %{name: "New Name", is_active: false})
Delete Consumer: This should cascade and delete all associated grants and tokens, cleaning up the system.
OAuth.delete_api_consumer(consumer)
# Cascades to delete all grants and tokens
5. Client Deactivation Behavior
What happens when a client is deactivated? We need predictable behavior.
- [ ] When
is_activeis set tofalse:- Existing tokens remain valid until expiry. Crucially, we don't revoke them immediately. This prevents breaking ongoing processes that might be using a valid token.
- New token requests should return an
invalid_clienterror. This is the primary indicator that the client is no longer allowed to authenticate. - The client should appear grayed out in the UI with an "Inactive" badge. This provides clear visual feedback to the user.
- [ ] Reactivation (
is_active=true) should immediately allow new token requests. This ensures a smooth transition back to active status.
6. Client Credentials Grant Implementation
This is a core part of the M2M flow. It allows clients to authenticate using their client_id and client_secret.
Token Endpoint: POST /oauth/token
Request: This is how a client will request a token.
POST /oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials
&client_id=cust_abc123...
&client_secret=secret_xyz...
&scope=dataset:read dataset:write
Validation Steps:
- Validate
grant_typeisclient_credentials. If not, reject. - Authenticate client via
client_idandclient_secret. This involves looking up the client byclient_idand verifying the providedclient_secretagainst the stored hash. - Check
client_credentialsis inallowed_grant_types. The client must explicitly allow this grant type. - Check client
is_activeistrue. Deactivated clients can't get new tokens. - Validate requested scopes are a subset of
allowed_scopes. The client can only request scopes it's been granted. - If all checks pass, generate and return a JWT access token.
Success Response:
{
"access_token": "eyJhbGciOiJSUzI1NiIs...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "dataset:read dataset:write"
}
Error Response (RFC 6749 §5.2): Standard OAuth 2.0 error responses are crucial for clients to understand what went wrong.
{
"error": "invalid_client",
"error_description": "Client authentication failed"
}
7. OpenBao Integration for JWT Signing
Storing private keys securely is a HUGE challenge. Environment variables or config files are NOT secure for private keys, folks. That's why we're integrating with OpenBao (an open-source Vault fork).
Why OpenBao?
- Private keys should never be in environment variables or config files. Seriously, never.
- OpenBao provides secure key management, including generation, signing, and rotation.
- It supports key rotation without requiring application restarts, which is massive for uptime.
Configuration: We'll need to configure the OpenBao address and token, likely at runtime.
# config/runtime.exs
config :csv_json_api, CsvJsonApi.OAuth.TokenGenerator,
openbao_addr: System.get_env("OPENBAO_ADDR"),
openbao_token: System.get_env("OPENBAO_TOKEN"),
key_name: "oauth-signing-key"
Implementation:
- [ ] Create
CsvJsonApi.OAuth.KeyManagermodule: This will encapsulate all interactions with OpenBao for key management. - [ ] Sign JWTs using OpenBao's Transit secrets engine: This offloads the cryptographic signing operation to a secure, dedicated service.
- [ ] Cache public keys locally for verification (refresh every 5 minutes): To avoid hitting OpenBao for every token validation, we'll cache the public keys. The keys need to be refreshed periodically to pick up rotations.
- [ ] Fallback: If OpenBao is unavailable, we must not fall back to local keys. Instead, we should return a
503 Service Unavailableerror and trigger a high-priority alert. This ensures that security isn't compromised due to temporary network issues or misconfiguration.
8. JWT Access Token Generation
Now we combine everything to generate the actual JWT access tokens.
Token Structure:
Header:
{
"alg": "RS256",
"typ": "JWT",
"kid": "key_2026_01_15_v1"
}
The kid (Key ID) is important for the JWKS endpoint, allowing consumers to know which key to use for verification.
Payload:
{
"iss": "https://api.csvjsonapi.com",
"aud": "https://api.csvjsonapi.com",
"sub": "cust_abc123...", // client_id for client_credentials
"azp": "cust_abc123...", // Authorized Party (same as sub for M2M)
"client_id": "cust_abc123...",
"scope": "dataset:read dataset:write",
"datasets": ["api_uuid_1", "api_uuid_2"],
"jti": "unique_token_id_uuid",
"iat": 1705334400, // Issued At
"nbf": 1705334400, // Not Before
"exp": 1705338000 // Expiration Time
}
Dataset Claim Population (Client Credentials):
This is where we link the token back to the data it can access. We'll populate the datasets array with all api_ids from the api_consumer_dataset_grants table that match the authenticated client.
9. JWT Access Token Validation
When an API receives a token, it needs to validate it thoroughly.
- [ ] Create
CsvJsonApiWeb.Plugs.OAuthAuthplug: This plug will intercept incoming requests and perform token validation. - [ ] Extract token from
Authorization: Bearer <token>header. We need to handle the standardBearertoken format. - [ ] Verify signature using the public key obtained from the JWKS endpoint. This ensures the token hasn't been tampered with.
- [ ] Validate claims: We must check
iss(issuer),aud(audience),exp(expiration time), andnbf(not before). We'll allow for a small clock skew tolerance (e.g., ±60 seconds) between servers. - [ ] Check
jtiagainst the revoked tokens table. If the token ID is found, it's invalid. - [ ] Extract
client_id,scope, anddatasetsfrom the validated token. These claims will be used for subsequent authorization checks within the API endpoints.
10. JWKS Endpoint
This endpoint provides the public keys necessary for verifying JWT signatures.
Endpoint: GET /.well-known/jwks.json
Response:
{
"keys": [
{
"kty": "RSA",
"use": "sig",
"kid": "key_2026_01_15_v1",
"alg": "RS256",
"n": "...", // Public modulus
"e": "AQAB" // Public exponent
}
]
}
Implementation:
- [ ] Create
CsvJsonApiWeb.WellKnownControllerwith ajwksaction. - [ ] Fetch public keys from OpenBao via the
KeyManager. - [ ] Include a
Cache-Control: public, max-age=3600header to encourage caching by clients and intermediate proxies. This reduces load on our OpenBao instance. - [ ] Support multiple keys for rotation: The JWKS endpoint should serve both the current signing key and potentially the previous key during a rotation period, allowing clients to seamlessly switch.
11. Update API Authentication
Existing APIs need to be updated to support JWT Bearer tokens in addition to API keys.
defmodule CsvJsonApiWeb.Plugs.ApiAuth do
def call(conn, _opts) do
case get_auth_header(conn) do
{:bearer, "csvj_" <> _ = api_key} ->
authenticate_api_key(conn, api_key)
{:bearer, jwt} ->
authenticate_jwt(conn, jwt) # New logic here!
{:query, "csvj_" <> _ = api_key} ->
authenticate_api_key(conn, api_key)
_ ->
unauthorized(conn)
end
end
end
We’ll modify the existing ApiAuth plug to check for both API key prefixes (csvj_) and JWT Bearer tokens. If it's a JWT, it will call a new authenticate_jwt function which uses our OAuthAuth plug or similar logic.
12. Security Event Logging
Auditing is crucial for security and compliance. We need to log significant OAuth events.
- [ ] Log all OAuth operations to the
audit_logstable:oauth.client.created: A new API Consumer was created.oauth.client.updated: Consumer settings were changed.oauth.client.deleted: An API Consumer was deleted.oauth.client.deactivated: A client was deactivated.oauth.token.granted: An access token was successfully issued.oauth.token.failed: A token request failed (log the reason).
- [ ] Include relevant context in logs:
actor_id(who performed the action, if applicable),client_id,ip_address,user_agent, andtimestamp.
UI Implementation
Let's talk about how users will interact with these new features through the user interface. A good UI makes complex security features accessible.
Page: API Consumers List (/settings/api-consumers)
This page will give users an overview of all the API clients they've registered.
| Element | Type | Description |
|---|---|---|
| Page Header | Text | "API Consumers" |
| Create Button | Primary Button | "Create API Consumer" - This kicks off the creation flow. |
| Consumers Table | Data Table | Lists all API consumers owned by the user. |
Table Columns:
- Name: The friendly name of the application.
- Client ID: The unique identifier (truncated with a copy button for convenience).
- Grant Type badge: Shows the allowed grant types (e.g.,
client_credentials). - Status toggle: A simple switch to activate/deactivate the client. This ties directly into the
is_activefield. - Created: A relative timestamp (e.g., "2 days ago") for when the client was created.
- Actions: A dropdown menu (kebab icon) with options like "View Details" and "Delete".
Empty State: When a user has no API consumers yet, we need a helpful message:
- Message: "You haven't created any API Consumers yet."
- A prominent, centered "Create API Consumer" button to guide them.
Page: Create API Consumer (/settings/api-consumers/new)
This is where users register a new M2M client.
Form Fields:
| Field | Type | Required | Validation |
|---|---|---|---|
| Application Name | Text | Yes | Must be between 3 and 100 characters. This maps to the name field. |
| Description | Textarea | No | Max 500 characters. Maps to the description field. |
| Client Type | Radio | Yes | Options: confidential (default for Phase 1), public. This determines security requirements later. |
On Success: This is a critical moment – the user is about to receive their secret!
- A modal dialog pops up, displaying:
- The generated
Client ID. - The generated
Client Secret.
- The generated
- Crucial Warning: A highly visible message like: "Warning: This is the only time your Client Secret will be displayed. Store it securely in a password manager or secrets vault. It cannot be recovered." This emphasizes the importance of saving the secret.
- A single "Done" button. Clicking this dismisses the modal and navigates the user to the details page for the newly created consumer.
Page: API Consumer Details (/settings/api-consumers/{client_id})
This page provides a comprehensive view and management interface for a specific API consumer.
Overview Tab:
- Application Name: Displayed prominently, ideally editable inline.
- Client ID: Read-only, with a copy button.
- Client Type: Displayed as a badge (e.g.,
confidential). - Status: Shows the current status (Active/Inactive) and allows toggling via the status toggle switch.
- Timestamps: Display
CreatedandLast Used(if available from token activity) timestamps.
Credentials Tab:
- Secret Prefix: Shows the truncated secret (e.g.,
cust_xxxx••••••••) for identification. - Last Rotated: Timestamp indicating when the secret was last rotated.
- Rotate Secret Button: A button that triggers a confirmation modal before initiating the secret rotation process. This is a key security operation.
Permissions Tab:
- Dataset Checkbox Table: A list of available datasets (APIs) with checkboxes for
Read,Write, andDeletepermissions for each. Users can grant or revoke access here. - Save Button: To persist any changes made to permissions.
Test Specifications
Testing is non-negotiable, especially for security features. We need comprehensive tests at multiple levels.
Unit Tests
These focus on individual functions and modules, ensuring they behave as expected in isolation. We'll use Elixir's built-in ExUnit framework.
# test/csv_json_api/oauth/oauth_test.exs
describe "client_id generation" do
test "generates 32 hex character client_id" do
client_id = OAuth.generate_client_id()
assert String.length(client_id) == 32
assert client_id =~ ~r/^[a-f0-9]+$/
end
test "generates unique client_ids" do
ids = for _ <- 1..1000, do: OAuth.generate_client_id()
assert length(Enum.uniq(ids)) == 1000
end
end
describe "client_secret generation" do
test "generates 256 bits of entropy" do
secret = OAuth.generate_client_secret()
# Base64 decode to check byte size
decoded = Base.decode64!(secret)
assert byte_size(decoded) == 32
end
test "generates unique secrets" do
secrets = for _ <- 1..100, do: OAuth.generate_client_secret()
assert length(Enum.uniq(secrets)) == 100
end
end
describe "secret hashing" do
test "uses Argon2id for hashing" do
secret = "test_secret"
hash = OAuth.hash_secret(secret)
assert String.starts_with?(hash, "$argon2id{{content}}quot;)
end
test "verifies correct secret" do
secret = "test_secret"
hash = OAuth.hash_secret(secret)
assert OAuth.verify_secret(secret, hash)
end
test "rejects incorrect secret" do
hash = OAuth.hash_secret("correct_secret")
refute OAuth.verify_secret("wrong_secret", hash)
end
end
describe "JWT payload" do
test "includes all required claims" do
consumer = insert(:api_consumer)
payload = TokenGenerator.build_payload(consumer, ["dataset:read"])
assert payload["iss"] == "https://api.csvjsonapi.com"
assert payload["aud"] == "https://api.csvjsonapi.com"
assert payload["sub"] == consumer.client_id
assert payload["client_id"] == consumer.client_id
assert is_binary(payload["jti"])
assert is_integer(payload["iat"])
assert is_integer(payload["nbf"])
assert is_integer(payload["exp"])
assert payload["exp"] > payload["iat"]
end
test "includes datasets from grants" do
consumer = insert(:api_consumer)
grant1 = insert(:api_consumer_grant, api_consumer: consumer)
grant2 = insert(:api_consumer_grant, api_consumer: consumer)
payload = TokenGenerator.build_payload(consumer, ["dataset:read"])
assert grant1.api_id in payload["datasets"]
assert grant2.api_id in payload["datasets"]
end
end
describe "JWKS endpoint" do
test "returns only public key material" do
jwks = OAuth.get_jwks()
for key <- jwks["keys"] do
assert Map.has_key?(key, "n") # public modulus
assert Map.has_key?(key, "e") # public exponent
refute Map.has_key?(key, "d") # private exponent
refute Map.has_key?(key, "p") # prime factor
refute Map.has_key?(key, "q") # prime factor
end
end
end
Integration Tests
These tests verify that different parts of the system work together correctly. We'll use test/support/conn_case.exs for setting up test connections.
# test/csv_json_api_web/controllers/oauth/token_controller_test.exs
describe "POST /oauth/token (client_credentials)" do
test "full flow: create client → request token → use token" do
# Create client
owner = insert(:user)
{:ok, %{consumer: consumer, client_secret: secret}} =
OAuth.create_api_consumer(owner, %{
name: "Test Client",
client_type: "confidential"
})
# Grant dataset access
api = insert(:api, user: owner)
OAuth.create_grant(consumer, api, ["read"])
# Request token
conn = post(conn, "/oauth/token", %{
grant_type: "client_credentials",
client_id: consumer.client_id,
client_secret: secret,
scope: "dataset:read"
})
assert %{"access_token" => token, "token_type" => "Bearer"} =
json_response(conn, 200)
# Use token to access API
conn = conn
|> put_req_header("authorization", "Bearer #{token}")
|> get("/api/v1/#{api.slug}")
assert json_response(conn, 200)
end
test "deactivated client receives invalid_client error" do
consumer = insert(:api_consumer, is_active: false)
conn = post(conn, "/oauth/token", %{
grant_type: "client_credentials",
client_id: consumer.client_id,
client_secret: "any_secret"
})
assert %{"error" => "invalid_client"} = json_response(conn, 401)
end
test "expired JWT is rejected with 401" do
# Assuming TokenGenerator can create an expired token for testing
# Let's simulate this scenario
token = "simulated_expired_token"
conn = conn
|> put_req_header("authorization", "Bearer #{token}")
|> get("/api/v1/test")
# The plug should catch this and return 401
assert json_response(conn, 401)
end
test "invalid JWT signature is rejected" do
token = "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.invalid_signature"
conn = conn
|> put_req_header("authorization", "Bearer #{token}")
|> get("/api/v1/test")
assert json_response(conn, 401)
end
test "secret rotation grace period allows both secrets" do
# Need to simulate secret rotation properly here
# Let's assume we have a way to create a consumer and then rotate its secret
consumer = insert(:api_consumer)
old_secret = "a_valid_old_secret"
# Simulate the hash for old_secret if needed, or assume verify_secret works
# Simulate rotation: new_secret is generated and old_secret remains valid for grace period
new_secret = "a_new_secret_after_rotation"
# Old secret still works during grace period
conn = post(conn, "/oauth/token", %{
grant_type: "client_credentials",
client_id: consumer.client_id,
client_secret: old_secret
})
assert json_response(conn, 200)
# New secret also works
conn = post(conn, "/oauth/token", %{
grant_type: "client_credentials",
client_id: consumer.client_id,
client_secret: new_secret
})
assert json_response(conn, 200)
end
test "kid header matches key in JWKS" do
# This requires the actual token generation and JWKS endpoint to be working
# For a unit/integration test, we might mock OpenBao or use test keys
conn = post(conn, "/oauth/token", valid_client_credentials())
%{"access_token" => token} = json_response(conn, 200)
# Peek at token header without verifying signature for test purposes
{:ok, header} = Joken.peek_header(token)
jwks_conn = get(conn, "/.well-known/jwks.json")
%{"keys" => keys} = json_response(jwks_conn, 200)
assert Enum.any?(keys, fn k -> k["kid"] == header["kid"] end)
end
end
Security Tests
These are specialized tests focusing on potential vulnerabilities and ensuring our security measures are effective.
describe "security" do
test "duplicate client_id is rejected" do
consumer = insert(:api_consumer)
# Assuming owner is available in the test context
owner = CsvJsonApi.Accounts.get_user!(consumer.owner_id)
assert {:error, changeset} = OAuth.create_api_consumer(owner, %{
name: "Duplicate",
client_id: consumer.client_id # Force duplicate
})
assert "has already been taken" in errors_on(changeset).client_id
end
test "malformed token requests return proper errors" do
# Missing grant_type
conn = post(conn, "/oauth/token", %{client_id: "test"})
assert %{"error" => "invalid_request"} = json_response(conn, 400)
# Invalid grant_type
conn = post(conn, "/oauth/token", %{grant_type: "password"})
assert %{"error" => "unsupported_grant_type"} = json_response(conn, 400)
end
test "client secrets are never exposed in API responses" do
owner = insert(:user)
{:ok, result} = OAuth.create_api_consumer(owner, valid_attrs()) # valid_attrs() should provide necessary fields
# Secret returned at creation - this is the only time!
assert Map.has_key?(result, :client_secret)
# Fetching the consumer again should NOT return the secret
fetched_consumer = OAuth.get_api_consumer!(result.consumer.id)
refute Map.has_key?(fetched_consumer, :client_secret)
# List response also must not include the secret
consumers = OAuth.list_api_consumers(owner)
for c <- consumers do
refute Map.has_key?(c, :client_secret)
end
end
test "timing-safe secret comparison" do
# This is a property test - we verify that the time taken for comparison
# doesn't leak information about whether the secret was correct or not.
secret = "correct_secret"
hash = OAuth.hash_secret(secret)
# Measure time for correct secret comparison
times_correct = for _ <- 1..100 do
{time, _} = :timer.tc(fn -> OAuth.verify_secret(secret, hash) end)
time
end
# Measure time for incorrect secret comparison
times_wrong = for _ <- 1..100 do
{time, _} = :timer.tc(fn -> OAuth.verify_secret("wrong_secret", hash) end)
time
end
# Calculate average times
avg_correct = Enum.sum(times_correct) / length(times_correct)
avg_wrong = Enum.sum(times_wrong) / length(times_wrong)
# Assert that the average times are similar (within a tolerance, e.g., 20%)
# This ensures a constant-time comparison implementation.
assert abs(avg_correct - avg_wrong) / avg_correct < 0.2
end
end
Edge Cases
It's the little things that can trip you up. Let's consider some edge cases to ensure our implementation is robust.
| Edge Case | Expected Behavior |
|---|---|
| Clock Skew | Token validation must allow for a ±60 second tolerance when checking nbf (Not Before) and exp (Expiration Time) claims to account for server clock differences. |
| Concurrent Secret Rotation | If a secret rotation is requested while a previous rotation is still within its grace period, the request should be rejected with an error like "Rotation already in progress". This prevents race conditions. |
| OpenBao Unavailable | If OpenBao (our secure key manager) is unreachable, the token endpoint must return a 503 Service Unavailable error. Crucially, there should be no fallback to local keys to maintain security. A high-priority alert should also be triggered. |
| Database Connection Lost | If the database connection is lost during a token request or client creation, the operation must fail gracefully with a 503 Service Unavailable error. No partial state should be written. |
| Very Long Client Names | Client names exceeding 100 characters should be caught by the changeset validation. An error should be returned to the user, clearly indicating the length constraint. |
| Empty Scopes Request | If a client requests an empty scope (scope=), the system should use the client's default allowed_scopes defined in its configuration. |
| Unknown Scope Requested | If a client requests a scope that is not in its allowed_scopes list, the token endpoint should return an invalid_scope error (as per OAuth 2.0 spec). |
| Multi-tenant Grant Violation | If the database trigger detects an attempt to grant access to an API from a different tenant (owner), it must raise an exception. The API endpoint handling this should return a 403 Forbidden error to the client. |
Files to Create/Modify
To keep things organized, here’s a list of the files we'll be creating or modifying:
New Files
priv/repo/migrations/YYYYMMDDHHMMSS_create_api_consumers.exs: Migration for theapi_consumerstable.priv/repo/migrations/YYYYMMDDHHMMSS_create_api_consumer_grants.exs: Migration for theapi_consumer_dataset_grantstable.priv/repo/migrations/YYYYMMDDHHMMSS_create_oauth_access_tokens.exs: Migration for theoauth_access_tokenstable.lib/csv_json_api/oauth/oauth.ex: The main context module for OAuth operations.lib/csv_json_api/oauth/api_consumer.ex: Schema and logic for API consumers.lib/csv_json_api/oauth/api_consumer_grant.ex: Schema and logic for API consumer grants.lib/csv_json_api/oauth/access_token.ex: Schema and logic for access tokens.lib/csv_json_api/oauth/token_generator.ex: Handles JWT generation and payload creation.lib/csv_json_api/oauth/key_manager.ex: Manages interaction with OpenBao for key management.lib/csv_json_api/oauth/grants/client_credentials.ex: Specific logic for the Client Credentials grant type.lib/csv_json_api_web/controllers/oauth/token_controller.ex: Handles requests to the/oauth/tokenendpoint.lib/csv_json_api_web/controllers/well_known_controller.ex: Handles the/.well-known/jwks.jsonendpoint.lib/csv_json_api_web/plugs/oauth_auth.ex: Plug for authenticating requests using JWTs.lib/csv_json_api_web/live/api_consumer_live/index.ex: LiveView for the API Consumers list page.lib/csv_json_api_web/live/api_consumer_live/new.ex: LiveView for the new API Consumer form.lib/csv_json_api_web/live/api_consumer_live/show.ex: LiveView for the API Consumer details page.
Modified Files
lib/csv_json_api_web/router.ex: We'll need to add routes for the token endpoint and the JWKS endpoint.lib/csv_json_api_web/plugs/api_auth.ex: This existing plug will be updated to support JWT Bearer token authentication.mix.exs: We'll add dependencies likejokenfor JWT handling and potentially a library for interacting with Vault/OpenBao (likeex_vaultor similar).config/config.exs(orruntime.exs): To configure OAuth settings, including OpenBao connection details.
Acceptance Criteria
Finally, how do we know we're done? These acceptance criteria define the success conditions for Phase 1:
- [ ] Can create an API Consumer via UI: Users can successfully navigate the UI, fill out the form, and receive their
client_idandclient_secretupon successful creation. - [ ] Can request access token via Client Credentials grant: M2M clients can successfully POST to the
/oauth/tokenendpoint with valid credentials and receive anaccess_token. - [ ] JWT tokens are signed and verifiable: All issued JWT access tokens are signed using RS256, and their signatures can be verified using the public keys exposed via the JWKS endpoint (
/.well-known/jwks.json). - [ ] JWT tokens contain correct claims: Valid tokens include all the required claims (
iss,aud,sub,client_id,scope,datasets,jti,exp,nbf) with the correct values populated. - [ ] API endpoints accept JWT Bearer tokens: Existing API endpoints, when protected by
ApiAuth, successfully authorize requests that include a valid JWT Bearer token in theAuthorizationheader. - [ ] Deactivated clients cannot obtain new tokens: If an
api_consumerhasis_activeset tofalse, any attempt to request a new token using its credentials results in aninvalid_clienterror. - [ ] Secret rotation works with 24-hour grace period: When a client's secret is rotated, both the old and new secrets are accepted for authentication for a 24-hour period (or defined grace period).
- [ ] Multi-tenant isolation trigger prevents cross-tenant grants: Attempts to create grants linking consumers and APIs from different owners are blocked by the database trigger, resulting in an error.
- [ ] All OAuth operations are logged to audit_logs: Every significant OAuth event (creation, deletion, token grant/failure, etc.) is recorded in the
audit_logstable with appropriate details. - [ ] All tests pass: All unit, integration, and security tests defined in the Test Specifications section pass without errors. This is the ultimate gatekeeper!