OAuth 2.0: Building Secure M2M Foundations

by Editorial Team 43 views
Iklan Headers

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_consumers table with all columns and indexes.
  • [ ] Create migration for api_consumer_dataset_grants table with multi-tenant trigger.
  • [ ] Create migration for oauth_access_tokens table.

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.OAuth context module as the main entry point.
  • [ ] Create CsvJsonApi.OAuth.ApiConsumer schema with changeset validations. This is where we’ll define the structure and validation rules for our API consumers.
  • [ ] Create CsvJsonApi.OAuth.ApiConsumerGrant schema. Similar to the above, but for the dataset grants.
  • [ ] Create CsvJsonApi.OAuth.AccessToken schema. 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_id generation: 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_secret generation: 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_active is set to false:
    • 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_client error. 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:

  1. Validate grant_type is client_credentials. If not, reject.
  2. Authenticate client via client_id and client_secret. This involves looking up the client by client_id and verifying the provided client_secret against the stored hash.
  3. Check client_credentials is in allowed_grant_types. The client must explicitly allow this grant type.
  4. Check client is_active is true. Deactivated clients can't get new tokens.
  5. Validate requested scopes are a subset of allowed_scopes. The client can only request scopes it's been granted.
  6. 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.KeyManager module: 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 Unavailable error 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.OAuthAuth plug: This plug will intercept incoming requests and perform token validation.
  • [ ] Extract token from Authorization: Bearer <token> header. We need to handle the standard Bearer token 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), and nbf (not before). We'll allow for a small clock skew tolerance (e.g., ±60 seconds) between servers.
  • [ ] Check jti against the revoked tokens table. If the token ID is found, it's invalid.
  • [ ] Extract client_id, scope, and datasets from 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.WellKnownController with a jwks action.
  • [ ] Fetch public keys from OpenBao via the KeyManager.
  • [ ] Include a Cache-Control: public, max-age=3600 header 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_logs table:
    • 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, and timestamp.

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_active field.
  • 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!

  1. A modal dialog pops up, displaying:
    • The generated Client ID.
    • The generated Client Secret.
  2. 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.
  3. 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 Created and Last 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, and Delete permissions 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 the api_consumers table.
  • priv/repo/migrations/YYYYMMDDHHMMSS_create_api_consumer_grants.exs: Migration for the api_consumer_dataset_grants table.
  • priv/repo/migrations/YYYYMMDDHHMMSS_create_oauth_access_tokens.exs: Migration for the oauth_access_tokens table.
  • 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/token endpoint.
  • lib/csv_json_api_web/controllers/well_known_controller.ex: Handles the /.well-known/jwks.json endpoint.
  • 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 like joken for JWT handling and potentially a library for interacting with Vault/OpenBao (like ex_vault or similar).
  • config/config.exs (or runtime.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_id and client_secret upon successful creation.
  • [ ] Can request access token via Client Credentials grant: M2M clients can successfully POST to the /oauth/token endpoint with valid credentials and receive an access_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 the Authorization header.
  • [ ] Deactivated clients cannot obtain new tokens: If an api_consumer has is_active set to false, any attempt to request a new token using its credentials results in an invalid_client error.
  • [ ] 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_logs table 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!