Search by

nks-hub / nette-bankid

lukyrys

Nette extension for BankID OAuth2/OIDC authentication (Czech/Slovak)

Package info

github.com/nks-hub/nette-bankid

pkg:composer/nks-hub/nette-bankid

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.1.0 2026-03-22 13:07 UTC

This package is auto-updated.

Last update: 2026-10-10 10:37:02 UTC


README

Latest Stable Version Total Downloads PHP Version Nette Version License

Nette DI extension for Czech BankID sign-in over OAuth2/OpenID Connect: the user confirms their identity in their own internet banking and your application receives verified name, email, birthdate and phone.

Only the Czech BankID is supported. Slovakia has no BankID service; country: 'sk' is accepted in the configuration but needs your own endpoints.

Features

  • ๐Ÿฆ BankID OAuth2/OIDC - Authorization URL, code exchange and userinfo call; requests LOA3 and forces a fresh login (prompt=login)
  • ๐Ÿ›ก๏ธ State, PKCE and nonce - Generates a state token and nonce, uses PKCE (S256), and compares both values in constant time
  • ๐Ÿงช Sandbox and production - The sandbox flag selects the endpoint set
  • ๐Ÿ‘ค User data - sub, email, names, birthdate, phone, address and level of assurance
  • ๐Ÿž Tracy panel - Each authentication step with its time in ms, also written to log/bankid.log
  • ๐ŸŒ Custom endpoints - Override the authorize, token and userinfo URLs
  • ๐ŸŽฏ Nette integration - Registers BankIdProvider in the DI container; storing users is up to your application

Requirements

  • PHP 8.1 โ€“ 8.4
  • Nette 3.0+ / 4.0+ (nette/di, nette/security, nette/http, nette/utils)
  • league/oauth2-client 2.7+
  • BankID developer account (developer.bankid.cz)

Installation

composer require nks-hub/nette-bankid

Configuration

1. Register the extension in config.neon

extensions:
    bankid: NksHub\NetteBankId\DI\BankIdExtension

2. Configure BankID credentials

bankid:
    clientId: 'your-client-id'
    clientSecret: 'your-client-secret'
    redirectUri: 'https://your-domain.com/bankid/callback'
    sandbox: false  # true for testing, false for production
    country: 'cz'   # 'cz' only (SK has no default endpoints)
    debug: false    # true = Tracy debug panel + logging

3. Use it in your application

Inject BankIdProvider into a presenter, see Usage.

All parameters

bankid:
    # Required parameters
    clientId: 'your-client-id'
    clientSecret: 'your-client-secret'
    redirectUri: 'https://your-domain.com/bankid/callback'

    # Optional parameters
    sandbox: false              # true = sandbox, false = production
    country: 'cz'              # 'cz' or 'sk'; 'sk' requires the custom endpoints below
    debug: false               # true = Tracy panel + file logging

    # Custom endpoints instead of the default CZ ones
    authorizeUrl: null         # null = CZ default according to sandbox mode
    tokenUrl: null
    userinfoUrl: null

Getting BankID credentials

Sandbox (testing)

  1. Visit the BankID Developer Portal
  2. Register as a developer
  3. Create a new application in the Sandbox
  4. Obtain client_id and client_secret
  5. Set redirect_uri (your application's callback URL)

Production

  1. Go through the certification process on the BankID portal
  2. Sign a contract with the BankID provider
  3. Obtain production credentials
  4. Switch sandbox: false in the configuration

Sandbox vs production mode

Sandbox (testing)

bankid:
    sandbox: true
    clientId: 'sandbox-client-id'
    clientSecret: 'sandbox-secret'
    redirectUri: 'http://localhost:8080/bankid/callback'

Test users: BankID provides test accounts in sandbox mode, see developer.bankid.cz.

Production

bankid:
    sandbox: false
    clientId: 'production-client-id'
    clientSecret: 'production-secret'
    redirectUri: 'https://your-domain.com/bankid/callback'

Production credentials are issued only after BankID certification.

Usage

Basic OAuth2 flow

<?php

namespace App\Presenters;

use Nette\Application\UI\Presenter;
use NksHub\NetteBankId\OAuth2\BankIdProvider;
use NksHub\NetteBankId\Exceptions\VerificationFailedException;

class AuthPresenter extends Presenter
{
    public function __construct(
        private BankIdProvider $bankIdProvider,
    ) {
        parent::__construct();
    }

    /**
     * Step 1: Redirect to BankID login
     */
    public function actionLogin(): void
    {
        $authUrl = $this->bankIdProvider->getAuthorizationUrl();

        // authenticate() compares these with the values returned by BankID
        $session = $this->getSession('bankid');
        $session->state = $this->bankIdProvider->getState();
        $session->nonce = $this->bankIdProvider->getNonce();

        // Redirect to BankID
        $this->redirectUrl($authUrl);
    }

    /**
     * Step 2: Callback from BankID
     */
    public function actionCallback(?string $code = null, ?string $state = null): void
    {
        if (!$code || !$state) {
            $this->flashMessage('Authentication failed - missing parameters', 'error');
            $this->redirect('Homepage:');
        }

        try {
            $session = $this->getSession('bankid');
            $result = $this->bankIdProvider->authenticate($code, $state, $session->state, $session->nonce);

            $userData = $result['user'];
            $accessToken = $result['token'];

            // Store user data in your database or session here
            $this->getSession('user')->userData = $userData;

            $this->flashMessage('Successfully signed in via BankID', 'success');
            $this->redirect('Homepage:');

        } catch (VerificationFailedException $e) {
            $this->flashMessage('Authentication failed: ' . $e->getMessage(), 'error');
            $this->redirect('Homepage:');
        }
    }
}

User data

$result = $this->bankIdProvider->authenticate($code, $state, $expectedState, $expectedNonce);
$userData = $result['user'];
$accessToken = $result['token'];

// Available user data:
$userData['sub'];           // BankID user ID (unique identifier)
$userData['email'];         // Email
$userData['given_name'];    // First name
$userData['family_name'];   // Last name
$userData['name'];          // Full name
$userData['birthdate'];     // Date of birth
$userData['phone_number'];  // Phone
$userData['acr'];           // Level of Assurance (loa1, loa2, loa3)
$userData['address'];       // Address (object)

// Access token info:
$accessToken->getToken();         // Access token string
$accessToken->getExpires();       // Expiration timestamp
$accessToken->getRefreshToken();  // Refresh token (if available)

Example: storing users with Doctrine

The extension has no storage of its own. One way to keep verified users:

Entity:

<?php

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
#[ORM\Table(name: 'bankid_verifications')]
class BankIdVerification
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column(type: 'integer')]
    private ?int $id = null;

    #[ORM\Column(type: 'string', unique: true)]
    private string $userId;  // BankID sub

    #[ORM\Column(type: 'string', nullable: true)]
    private ?string $email = null;

    #[ORM\Column(type: 'string', nullable: true)]
    private ?string $fullName = null;

    #[ORM\Column(type: 'datetime_immutable')]
    private \DateTimeImmutable $verifiedAt;

    // ... more properties as needed
}

Service:

<?php

namespace App\Service;

use Doctrine\ORM\EntityManagerInterface;
use App\Entity\BankIdVerification;

class BankIdService
{
    public function __construct(
        private EntityManagerInterface $em,
    ) {
    }

    public function saveVerification(array $userData): BankIdVerification
    {
        $verification = new BankIdVerification();
        $verification->setUserId($userData['sub']);
        $verification->setEmail($userData['email'] ?? null);
        $verification->setFullName($userData['name'] ?? null);
        $verification->setVerifiedAt(new \DateTimeImmutable());

        $this->em->persist($verification);
        $this->em->flush();

        return $verification;
    }
}

Debugging and Tracy panel

Enabling debug mode

bankid:
    debug: true  # Tracy panel and logging

Tracy debug panel

With debug: true the Tracy bar gets a BankID panel with:

  1. Basic info:

    • Mode: Sandbox / Production
    • Redirect URI
    • Number of events
  2. Authenticated user data (after a successful authentication):

    • sub (BankID user ID)
    • email
    • name, given_name, family_name
    • birthdate
    • phone_number
    • acr (Level of Assurance)
    • other available fields
  3. Authentication flow:

    • Timing of each step (ms)
    • Event description (authenticate started, token retrieved, user info retrieved)
    • Context data for each event

File logging

The same events are written to:

log/bankid.log

Log format:

[timestamp] Message
{
    "context": {...}
}

Example Tracy output

BankID (3)
โ”œโ”€ Mode: Sandbox
โ”œโ”€ Redirect URI: https://your-domain.com/bankid/callback
โ””โ”€ Logs: 3 events

Authenticated User Data:
โ”œโ”€ sub: 1234567890
โ”œโ”€ email: user@example.com
โ”œโ”€ name: Jan Novรกk
โ”œโ”€ given_name: Jan
โ”œโ”€ family_name: Novรกk
โ”œโ”€ birthdate: 1990-01-01
โ””โ”€ acr: loa3

Authentication Flow:
โ”œโ”€ +0.00ms   BankID authenticate started
โ”‚            code_length: 24, state: abc123...
โ”œโ”€ +125.50ms Access token retrieved
โ”‚            expires: 1234567890, has_refresh_token: false
โ””โ”€ +234.12ms User info retrieved
             sub: 1234567890, email: user@example.com, acr: loa3

Disabling debug mode in production

The panel and the log contain personal data from BankID, so keep debug off in production:

# Production config
bankid:
    debug: false  # โ† Disabled for production
    sandbox: false

Security

1. State and nonce

The extension generates the state token and nonce, but your application keeps them in the session between the redirect and the callback. authenticate() throws VerificationFailedException when either does not match:

// $expectedState and $expectedNonce come from the session saved in actionLogin()
$result = $this->bankIdProvider->authenticate($code, $state, $expectedState, $expectedNonce);

Passing null as the nonce skips the nonce check.

2. Encrypting access tokens in the database

If you store access_token and refresh_token, encrypt them:

// Use a Doctrine encrypted column type or your own listener
use Doctrine\ORM\Mapping as ORM;

#[ORM\Column(type: 'encrypted_text')]
private string $accessToken;

3. HTTPS only

BankID requires HTTPS for the callback URL in production.

bankid:
    redirectUri: 'https://your-domain.com/bankid/callback'  # HTTPS!

4. Token expiration

Access tokens expire. Check the expiry on your own entity (isTokenValid() is your method, not part of the extension):

if (!$verification->isTokenValid()) {
    // Token expired, request a new authentication
}

5. Data retention

Delete stored verifications you no longer need (again your own repository method):

// For example in a cron job
$repository->deleteExpired(new \DateTimeImmutable('-90 days'));

Level of Assurance (LOA)

BankID has three levels of verification:

  • LOA1 - Low (basic identification)
  • LOA2 - Medium (verification by document)
  • LOA3 - High (banking identity), requested by the extension
$loa = $this->bankIdProvider->getAssuranceLevel($userData); // 'loa3'

Troubleshooting

Error: "Invalid redirect_uri"

Cause: The redirect URI in the configuration does not match the URI registered in the BankID portal.

Solution: Check that redirectUri in config.neon exactly matches the URI in the BankID developer portal (including http/https, port, path).

Error: "State token mismatch"

Cause: The state returned by BankID differs from the one in the session, either a forged request or a lost session.

Solution:

  • Check that sessions are configured correctly in Nette
  • Make sure cookies work (the session ID travels in a cookie)
  • Check that the user was not redirected through a different proxy/load balancer

Sandbox mode does not work

Cause: The sandbox has different endpoints than production.

Solution: Make sure you have sandbox: true in the configuration:

bankid:
    sandbox: true

Access token expired

Cause: Access tokens have limited validity (typically 1 hour).

Solution: Use the refresh token or request a new authentication:

if (!$verification->isTokenValid()) {
    // Request a new sign-in
    $this->redirect('Auth:login');
}

Development

# Install dependencies
composer install

# Run static analysis
vendor/bin/phpstan analyse src

Contributing

Contributions are welcome! For major changes, please open an issue first.

  1. Fork the repository
  2. Create your feature branch (git checkout -b feature/amazing-feature)
  3. Commit your changes (git commit -m 'feat: description')
  4. Push to the branch (git push origin feature/amazing-feature)
  5. Open a Pull Request

Support

License

MIT License โ€” see LICENSE for details.

Made with โค๏ธ by NKS Hub