API Reference

View Source

This section provides comprehensive API documentation for the BankID authentication strategy.

HTTP Endpoints

All BankID authentication endpoints are automatically generated by Ash Authentication based on your configuration. The base path follows the pattern: /{subject_name}/{strategy_name}

Initiate Authentication

Endpoint: POST /{subject_name}/{strategy_name}/initiate

Starts a new BankID authentication order.

Request Headers

Content-Type: application/json
Accept: application/json

Request Body

{
  "return_url": "https://yourapp.com/auth/callback",
  "device_info": {
    "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
    "ip_address": "192.168.1.100"
  },
  "auto_start": false
}

Parameters:

  • return_url (optional, string) - URL to redirect after successful authentication
  • device_info (optional, object) - Device information for security logging
  • auto_start (optional, boolean) - Set to true for same-device authentication

Response

Success (200 OK):

{
  "status": "pending",
  "order_ref": "131daac9-16c6-4333-99c4-2e12fb44897c",
  "auto_start_token": "c23b5106-d0f2-4252-8c45-83f63ab0c221",
  "qr_start_token": "6b33e2b4-9f4a-4d8b-8c3a-1d9e5f7a2b3c",
  "qr_start_secret": "a1b2c3d4e5f6789012345678901234567890abcd",
  "expires_at": "2024-01-01T12:05:00Z"
}

Error (400 Bad Request):

{
  "error": "invalid_request",
  "message": "Invalid device info format"
}

Error (500 Internal Server Error):

{
  "error": "bankid_error",
  "message": "Failed to create BankID order",
  "details": {
    "code": "alreadyInProgress",
    "hint_code": "alreadyInProgress"
  }
}

Poll Authentication Status

Endpoint: GET /{subject_name}/{strategy_name}/poll

Checks the status of an authentication order.

Query Parameters

  • order_ref (required, string) - The order reference from initiate response

Response

Pending (200 OK):

{
  "status": "pending",
  "hint_code": "outstandingTransaction",
  "expires_at": "2024-01-01T12:05:00Z"
}

Order Renewed (200 OK):

{
  "status": "pending",
  "hint_code": "orderExpired",
  "auto_start_token": "new-token-here",
  "qr_start_token": "new-qr-token-here",
  "expires_at": "2024-01-01T12:06:00Z"
}

Complete (200 OK):

{
  "status": "complete",
  "completion_data": {
    "user": {
      "personal_number": "199001011234",
      "name": "Anna Svensson",
      "given_name": "Anna",
      "surname": "Svensson"
    },
    "device": {
      "ip_address": "192.168.1.100"
    },
    "bankid_issue_date": "2024-01-01T12:03:45Z",
    "signature": "<base64-encoded-signature>",
    "ocsp_response": "<base64-encoded-ocsp-response>"
  }
}

Failed (200 OK):

{
  "status": "failed",
  "hint_code": "userCancel"
}

Error (400 Bad Request):

{
  "error": "invalid_order_ref",
  "message": "Invalid or missing order_ref parameter"
}

Error (404 Not Found):

{
  "error": "order_not_found",
  "message": "Authentication order not found"
}

Renew Authentication Order

Endpoint: POST /{subject_name}/{strategy_name}/renew

Creates a new order when the current one expires.

Request Headers

Content-Type: application/json

Request Body

{
  "order_ref": "131daac9-16c6-4333-99c4-2e12fb44897c"
}

Response

Same format as the initiate endpoint with a new order reference.

Complete Sign-in

Endpoint: POST /{subject_name}/{strategy_name}

Completes the authentication and creates a user session.

Request Headers

Content-Type: application/json

Request Body

{
  "order_ref": "131daac9-16c6-4333-99c4-2e12fb44897c",
  "completion_data": {
    "user": {
      "personal_number": "199001011234",
      "name": "Anna Svensson",
      "given_name": "Anna",
      "surname": "Svensson"
    },
    "device": {
      "ip_address": "192.168.1.100"
    },
    "bankid_issue_date": "2024-01-01T12:03:45Z"
  }
}

Response

Success (200 OK):

{
  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "refresh_token": "def502003232a6c3b8b0b8c6e5d2f9a1...",
  "expires_in": 3600,
  "user": {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "personal_number": "199001011234",
    "given_name": "Anna",
    "surname": "Svensson",
    "bankid_verified_at": "2024-01-01T12:03:45Z"
  }
}

Error (400 Bad Request):

{
  "error": "invalid_completion_data",
  "message": "Missing or invalid completion data"
}

Error (401 Unauthorized):

{
  "error": "authentication_failed",
  "message": "Authentication could not be completed"
}

Error Codes

BankID Hint Codes

Hint CodeDescriptionRecommended Action
outstandingTransactionUser has not yet opened BankID appContinue polling
noClientNo BankID client found on deviceShow "install BankID" message
startedUser has opened BankID appContinue polling
userSignUser is confirming identificationContinue polling
alreadyInProgressAuthentication already in progressUse existing order
userCancelUser cancelled authenticationShow "try again" button
expiredTransactionTransaction expiredShow "try again" button
certificateErrCertificate errorShow generic error message
unknownUnknown error occurredShow generic error message

API Error Codes

Error CodeHTTP StatusDescription
invalid_request400Request body is malformed
invalid_order_ref400Invalid order reference format
order_not_found404Order does not exist or expired
order_already_consumed400Order already used for authentication
order_expired400Order has expired
completion_data_missing400No completion data provided
completion_data_invalid400Invalid completion data format
bankid_error500BankID API returned error
internal_error500Server internal error

Configuration Options

Strategy Configuration

bank_id do
  order_resource MyApp.Accounts.BankIDOrder
  personal_number_field :personal_number
  given_name_field :given_name
  surname_field :surname
  verified_at_field :bankid_verified_at
  ip_address_field :ip_address
  order_ttl 300
  order_renewal_interval 28
  max_renewals 10
  poll_interval 2000
  cleanup_interval 300_000
  consumed_order_ttl 86_400
end

Field Mappings

OptionTypeDefaultDescription
order_resourceatomrequiredAsh resource for storing orders
personal_number_fieldatom:personal_numberField for Swedish personal number
given_name_fieldatom:given_nameField for given name
surname_fieldatom:surnameField for surname
verified_at_fieldatom:bankid_verified_atField for verification timestamp
ip_address_fieldatom:ip_addressField for IP address

Timing Configuration

OptionTypeDefaultDescription
order_ttlpos_integer300Total auth window in seconds
order_renewal_intervalpos_integer28Renewal interval in seconds
max_renewalspos_integer10Maximum number of renewals
poll_intervalpos_integer2000Recommended poll interval in ms
cleanup_intervalpos_integer300000Cleanup interval in ms
consumed_order_ttlpos_integer86400Retention time for consumed orders

Data Models

User Resource Fields

These fields are required on your user resource:

attributes do
  attribute :personal_number, :string, allow_nil?: false
  attribute :given_name, :string, allow_nil?: false
  attribute :surname, :string, allow_nil?: false
  attribute :bankid_verified_at, :utc_datetime_usec, allow_nil?: true
  attribute :ip_address, :string, allow_nil?: true
end

identities do
  identity :unique_personal_number, [:personal_number]
end

Order Resource Fields

Required fields for the BankID order resource:

attributes do
  attribute :order_ref, :string, allow_nil?: false
  attribute :status, :atom, default: :pending
  attribute :auto_start_token, :string, allow_nil?: false
  attribute :qr_start_token, :string, allow_nil?: true
  attribute :qr_start_secret, :string, allow_nil?: true, sensitive?: true
  attribute :expires_at, :utc_datetime_usec, allow_nil?: false
  attribute :consumed_at, :utc_datetime_usec, allow_nil?: true
  attribute :session_id, :string, allow_nil?: false
  attribute :completion_data, :map, allow_nil?: true
  attribute :ip_address, :string, allow_nil?: true
end

identities do
  identity :unique_order_ref, [:order_ref]
end

JavaScript Client Examples

Basic Usage

class BankIDClient {
  constructor(baseUrl) {
    this.baseUrl = baseUrl;
    this.pollInterval = 2000;
  }

  async initiate(deviceInfo = {}) {
    const response = await fetch(`${this.baseUrl}/user/bank_id/initiate`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ device_info: deviceInfo })
    });
    
    if (!response.ok) throw new Error('Failed to initiate');
    return await response.json();
  }

  async poll(orderRef) {
    const response = await fetch(`${this.baseUrl}/user/bank_id/poll?order_ref=${orderRef}`);
    if (!response.ok) throw new Error('Poll failed');
    return await response.json();
  }

  async authenticate(deviceInfo = {}) {
    const initResult = await this.initiate(deviceInfo);
    
    return new Promise((resolve, reject) => {
      const pollTimer = setInterval(async () => {
        try {
          const pollResult = await this.poll(initResult.order_ref);
          
          if (pollResult.status === 'complete') {
            clearInterval(pollTimer);
            const signInResult = await this.signIn(initResult.order_ref, pollResult.completion_data);
            resolve(signInResult);
          } else if (pollResult.status === 'failed') {
            clearInterval(pollTimer);
            reject(new Error(`Authentication failed: ${pollResult.hint_code}`));
          } else if (pollResult.auto_start_token) {
            // Order was renewed
            initResult.auto_start_token = pollResult.auto_start_token;
            initResult.qr_start_token = pollResult.qr_start_token;
          }
        } catch (error) {
          clearInterval(pollTimer);
          reject(error);
        }
      }, this.pollInterval);
    });
  }

  async signIn(orderRef, completionData) {
    const response = await fetch(`${this.baseUrl}/user/bank_id`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ order_ref: orderRef, completion_data: completionData })
    });
    
    if (!response.ok) throw new Error('Sign-in failed');
    return await response.json();
  }
}

QR Code Generation

import QRCode from 'qrcode';

class QRCodeGenerator {
  static generateAnimatedQR(qrStartToken, qrStartSecret) {
    return (time) => {
      const timeStr = time.toString().padStart(10, '0');
      const data = `${qrStartToken}${timeStr}`;
      const hash = this.simpleHash(data + qrStartSecret);
      return `${qrStartToken}${timeStr}${hash}`;
    };
  }

  static simpleHash(str) {
    let hash = 0;
    for (let i = 0; i < str.length; i++) {
      const char = str.charCodeAt(i);
      hash = ((hash << 5) - hash) + char;
      hash = hash & hash;
    }
    return Math.abs(hash).toString(16).padStart(16, '0');
  }

  static async renderQRCode(canvas, data) {
    await QRCode.toCanvas(canvas, data, {
      width: 256,
      margin: 2,
      color: {
        dark: '#000000',
        light: '#FFFFFF'
      }
    });
  }
}

// Usage
const qrGenerator = QRCodeGenerator.generateAnimatedQR(qrStartToken, qrStartSecret);
const canvas = document.getElementById('qr-code');

// Update QR code every second
setInterval(() => {
  const time = Math.floor(Date.now() / 1000);
  const qrData = qrGenerator(time);
  QRCodeGenerator.renderQRCode(canvas, qrData);
}, 1000);

Security Considerations

Session Binding

Orders are bound to the Phoenix session ID to prevent session hijacking:

# The session_id is automatically extracted and validated
%BankIDOrder{session_id: session_id} = order

Sensitive Data Protection

  • qr_start_secret is marked as sensitive and never exposed to clients
  • All certificates and private keys should be protected with proper file permissions
  • User personal numbers are stored securely in the database

Rate Limiting

Consider implementing rate limiting for:

  • Initiate requests per IP
  • Poll requests per order
  • Total requests per user

Example with Phoenix plug:

# In your router
pipeline :rate_limit do
  plug Hammer.Plug, 
    rate_limit: {"auth:requests", 60_000, 100}, # 100 requests per minute
    by: :ip
end

scope "/auth" do
  pipe_through [:api, :rate_limit]
  # ... your auth routes
end

Testing

Mock BankID Client

For testing, you can mock the BankID client:

# test/support/bankid_mock.ex
defmodule BankIDMock do
  def auth(params, _opts) do
    case params["personalNumber"] do
      "199001011234" ->
        {:ok, %{
          "orderRef" => "test-order-ref",
          "autoStartToken" => "test-auto-start",
          "qrStartToken" => "test-qr-token",
          "qrStartSecret" => "test-secret"
        }}
      _ ->
        {:error, "invalidPersonalNumber"}
    end
  end

  def collect(order_ref, _opts) do
    if order_ref == "test-order-ref" do
      {:ok, %{
        "status" => "complete",
        "completionData" => %{
          "user" => %{
            "personalNumber" => "199001011234",
            "name" => "Test User",
            "givenName" => "Test",
            "surname" => "User"
          }
        }
      }}
    else
      {:ok, %{"status" => "pending"}}
    end
  end
end

Integration Tests

defmodule MyAppWeb.BankIDAuthTest do
  use MyAppWeb.ConnCase

  test "initiate authentication", %{conn: conn} do
    conn = post(conn, "/auth/user/bank_id/initiate", %{
      "return_url" => "https://example.com/callback"
    })

    assert json_response(conn, 200)["status"] == "pending"
    assert json_response(conn, 200)["order_ref"]
  end

  test "complete authentication flow", %{conn: conn} do
    # Initiate
    conn = post(conn, "/auth/user/bank_id/initiate", %{})
    order_ref = json_response(conn, 200)["order_ref"]

    # Mock completion
    # ... set up test completion data

    # Complete sign-in
    conn = post(conn, "/auth/user/bank_id", %{
      "order_ref" => order_ref,
      "completion_data" => completion_data
    })

    assert json_response(conn, 200)["access_token"]
  end
end