nks-hub / nette-bankid
Nette extension for BankID OAuth2/OIDC authentication (Czech/Slovak)
Requires
- php: >=8.1 <8.5
- league/oauth2-client: ^2.7
- nette/di: ^3.0 || ^4.0
- nette/http: ^3.0 || ^4.0
- nette/security: ^3.0 || ^4.0
- nette/utils: ^3.0 || ^4.0
Requires (Dev)
- nette/bootstrap: ^3.0
- nette/tester: ^2.5
- phpstan/phpstan: ^1.10
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
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
sandboxflag 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
BankIdProviderin 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)
- Visit the BankID Developer Portal
- Register as a developer
- Create a new application in the Sandbox
- Obtain
client_idandclient_secret - Set
redirect_uri(your application's callback URL)
Production
- Go through the certification process on the BankID portal
- Sign a contract with the BankID provider
- Obtain production credentials
- Switch
sandbox: falsein 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:
-
Basic info:
- Mode: Sandbox / Production
- Redirect URI
- Number of events
-
Authenticated user data (after a successful authentication):
- sub (BankID user ID)
- name, given_name, family_name
- birthdate
- phone_number
- acr (Level of Assurance)
- other available fields
-
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.
- Fork the repository
- Create your feature branch (
git checkout -b feature/amazing-feature) - Commit your changes (
git commit -m 'feat: description') - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
Support
- ๐ง Email: dev@nks-hub.cz
- ๐ Bug reports: GitHub Issues
- ๐ BankID docs: developer.bankid.cz
License
MIT License โ see LICENSE for details.
Made with โค๏ธ by NKS Hub