BankID.Client (bankid v0.0.2)

Copy Markdown View Source

Core BankID client for Swedish BankID authentication and signing.

This module provides a pure Elixir implementation for integrating with the Swedish BankID API, using mTLS (mutual TLS) for secure communication.

Features

  • Pure Elixir - No external dependencies beyond Req and QRCodeEx
  • mTLS Support - Secure client certificate authentication
  • Authentication & Signing - Full support for BankID auth and sign operations
  • Test & Production - Bundled test certificates, easy production setup
  • HTTP Client - Built on Req with proper error handling

Usage

# Start authentication (returns tokens for QR and polling)
{:ok, auth} = BankID.authenticate("192.168.1.1")

# Poll for status
{:ok, result} = BankID.collect(auth.order_ref)

case result.status do
  "pending" -> # Keep polling every 2 seconds
  "complete" -> # Success! User authenticated
  "failed" -> # Handle error based on result.hint_code
end

# Cancel if needed
:ok = BankID.cancel(auth.order_ref)

Configuration

For production use, you can configure certificates in two ways:

Option 1: Direct Content (Recommended for Serverless)

config :bankid,
  base_url: "https://appapi2.bankid.com/rp/v6.0",
  cert: System.get_env("BANKID_CERT"),
  key: System.get_env("BANKID_KEY")

Option 2: File Paths (Traditional)

config :bankid,
  base_url: "https://appapi2.bankid.com/rp/v6.0",
  cert_path: System.get_env("BANKID_CERT_PATH"),
  key_path: System.get_env("BANKID_KEY_PATH")

Summary

Functions

Initiate a BankID authentication process.

Cancel an ongoing BankID authentication process.

Collect the result of a BankID authentication process.

Extract user information from BankID completion data.

Types

auth_response()

@type auth_response() :: %{
  order_ref: String.t(),
  auto_start_token: String.t(),
  qr_start_token: String.t(),
  qr_start_secret: String.t(),
  start_t: integer()
}

collect_response()

@type collect_response() :: %{
  order_ref: String.t(),
  status: String.t(),
  hint_code: String.t() | nil,
  completion_data: map() | nil
}

completion_data()

@type completion_data() :: %{
  user: map(),
  device: map(),
  signature: String.t(),
  ocsp_response: String.t()
}

options()

@type options() :: keyword()

user_info()

@type user_info() :: %{
  personal_number: String.t(),
  given_name: String.t(),
  surname: String.t()
}

Functions

authenticate(end_user_ip, opts \\ [])

@spec authenticate(String.t(), options()) :: {:ok, auth_response()} | {:error, term()}

Initiate a BankID authentication process.

Parameters

  • end_user_ip: IP address of the end user
  • opts: Optional keyword list with:
    • :personal_number - Require specific user (Swedish personnummer)
    • :user_visible_data - Text to display to user
    • :user_non_visible_data - Data not displayed to user
    • :user_visible_data_format - "simpleMarkdownV1" for markdown formatting
    • :certificate_policies - List of allowed certificate policies (SECURITY CRITICAL)
    • :requirement - Custom requirement map (overrides :personal_number if both provided)
    • :http_client - Pre-configured HTTPClient (optional)

Certificate Policies (SECURITY CRITICAL)

Certificate policies prevent relay attacks by specifying which authentication methods are allowed. The library sends certificate policies in production mode.

Available policies:

  • ["1.2.752.78.1.5"] - Mobile BankID on another device (QR code flow)
  • ["1.2.752.78.1.2"] - BankID on same device (deep link flow)
  • ["1.2.752.78.1.5", "1.2.752.78.1.2"] - Allow both flows - DEFAULT (production)

Production behavior: Certificate policies are ALWAYS sent to prevent relay attacks

  • Defaults to allowing both flows to support mobile and desktop
  • Can be restricted if you know your use case (desktop-only or mobile-only)

Test mode behavior: Certificate policies are SKIPPED automatically

  • Test certificates don't have production certificate policies
  • Sending policies with test certs causes "Digital ID missing" errors
  • This is detected automatically based on base_url configuration

You can restrict to one flow if you know your use case:

  • Desktop-only web app → Use QR-only: ["1.2.752.78.1.5"]
  • Mobile-only app → Use same-device only: ["1.2.752.78.1.2"]

The critical security improvement is that we ALWAYS send certificate policies in production (unlike the vulnerable implementations that sent none). Combined with IP validation and session binding, this prevents session fixation attacks.

Returns

{:ok, data} on success with authentication details {:error, reason} on failure

Response Data

%{
  order_ref: "...",           # Used for polling with collect/1
  auto_start_token: "...",    # For same-device flow
  qr_start_token: "...",      # For QR code generation
  qr_start_secret: "...",     # For QR code generation (keep secret!)
  start_t: 1234567890         # Unix timestamp for QR code generation
}

Examples

# Simple authentication (allows both mobile and desktop)
{:ok, auth} = BankID.authenticate("192.168.1.1")

# Desktop-only (QR code flow only)
{:ok, auth} = BankID.authenticate("192.168.1.1",
  certificate_policies: ["1.2.752.78.1.5"]
)

# Mobile-only (same device flow only)
{:ok, auth} = BankID.authenticate("192.168.1.1",
  certificate_policies: ["1.2.752.78.1.2"]
)

# Require specific user
{:ok, auth} = BankID.authenticate("192.168.1.1",
  personal_number: "199001011234"
)

# With visible data
{:ok, auth} = BankID.authenticate("192.168.1.1",
  user_visible_data: "Login to MyApp",
  user_visible_data_format: "simpleMarkdownV1"
)

Security Notes

⚠️ CRITICAL: This function alone does not prevent session fixation attacks. You must also:

  1. Store the returned order_ref bound to the user's session
  2. Validate IP address in collect/2 using :expected_ip option
  3. Only allow the session that created the order_ref to collect results

See the Security section in README.md for complete implementation guidance.

cancel(order_ref, opts \\ [])

@spec cancel(String.t(), options()) :: :ok | {:error, term()}

Cancel an ongoing BankID authentication process.

Parameters

  • order_ref: The order reference from authentication response
  • opts: Optional keyword list with:
    • :http_client - Pre-configured HTTPClient (optional)

Returns

:ok on success {:error, reason} on failure

Examples

{:ok, auth} = BankID.authenticate("192.168.1.1")
:ok = BankID.cancel(auth.order_ref)

collect(order_ref, opts \\ [])

@spec collect(String.t(), options()) :: {:ok, collect_response()} | {:error, term()}

Collect the result of a BankID authentication process.

Parameters

  • order_ref: The order reference from authentication response
  • opts: Optional keyword list with:
    • :expected_ip - IP address that initiated authentication (SECURITY CRITICAL)
    • :http_client - Pre-configured HTTPClient (optional)

IP Address Validation (SECURITY CRITICAL)

⚠️ ALWAYS provide :expected_ip to prevent cross-IP attacks!

The :expected_ip option validates that the user completing authentication has the same IP address as the user who initiated it. This prevents an attacker from tricking a victim into authenticating for the attacker's session.

If :expected_ip is provided and doesn't match, returns {:error, :ip_mismatch}. If :expected_ip is not provided, a warning is logged but collection proceeds.

Returns

{:ok, data} on success with authentication status and result {:error, reason} on failure

Response Data

The response data varies based on the status:

Pending

%{
  order_ref: "...",
  status: "pending",
  hint_code: "outstandingTransaction" | "noClient" | "started" | "userSign"
}

Complete

%{
  order_ref: "...",
  status: "complete",
  completion_data: %{
    user: %{personal_number: "...", name: "...", ...},
    device: %{ip_address: "..."},
    signature: "...",
    ocsp_response: "..."
  }
}

Failed

%{
  order_ref: "...",
  status: "failed",
  hint_code: "userCancel" | "expiredTransaction" | ...
}

Examples

# SECURE: With IP validation (RECOMMENDED)
{:ok, auth} = BankID.authenticate(user_ip)
# Store user_ip with order_ref in your session/database

{:ok, result} = BankID.collect(auth.order_ref, expected_ip: user_ip)

case result.status do
  "pending" -> # Keep polling
  "complete" -> # Success! Get user from result.completion_data.user
  "failed" -> # Handle failure based on result.hint_code
end

# INSECURE: Without IP validation (NOT RECOMMENDED)
{:ok, result} = BankID.collect(auth.order_ref)
# Warning will be logged but collection proceeds

Security Notes

⚠️ CRITICAL: This function alone does not prevent session fixation attacks. You must also:

  1. Verify the order_ref belongs to the current user's session
  2. Use certificate policies in authenticate/2
  3. Never expose order_ref to other users

See the Security section in README.md for complete implementation guidance.

extract_user_info(completion_data)

@spec extract_user_info(map()) :: user_info()

Extract user information from BankID completion data.

Parameters

  • completion_data: The completion data map from a successful collect response

Returns

A map with user information including personal_number, given_name, and surname

Examples

{:ok, result} = BankID.collect(order_ref)

if result.status == "complete" do
  user_info = BankID.extract_user_info(result.completion_data)
  # %{
  #   "personal_number" => "199001011234",
  #   "given_name" => "Erik",
  #   "surname" => "Andersson"
  # }
end