nks-hub / nette-sso
Nette extension for SSO authentication via OAuth2/OpenID Connect (Authentik)
Requires
- php: >=8.1 <8.5
- league/oauth2-client: ^2.7
- nette/di: ^3.0
- nette/http: ^3.0
- nette/schema: ^1.2
- nette/security: ^3.0
- nette/utils: ^3.0 || ^4.0
Requires (Dev)
- nette/tester: ^2.5
- phpstan/phpstan: ^1.10
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Nette DI extension that logs users in through an OAuth2/OpenID Connect provider (built against Authentik) and turns the userinfo response into a Nette identity.
Features
- 🔐 OAuth2/OpenID Connect - Authentication flow with PKCE (S256) and nonce validation
- 🛡️ CSRF protection - State parameter compared with the session copy using
hash_equals() - 🎭 Role mapping - OAuth2 groups mapped to Nette roles
- 👮 Admin access control -
hasAdminAccess()checks the user's groups againstadminGroups - 🎯 Nette Security integration -
createIdentity()builds aSimpleIdentityfrom userinfo data - 💾 Session storage - State, PKCE verifier and nonce kept in the
ssosession section and cleared after the callback - 🐘 PHP 8.1+ support - 8.1, 8.2, 8.3, 8.4
- ✅ Strict types -
declare(strict_types=1)in every source file
Requirements
- PHP 8.1 or higher
- Nette Framework 3.0+
- league/oauth2-client 2.7+
Installation
Install via Composer:
composer require nks-hub/nette-sso
Configuration
Register the extension in your Nette configuration file:
extensions: sso: NksHub\NetteSso\DI\SsoExtension sso: clientId: 'your-client-id' clientSecret: 'your-client-secret' redirectUri: 'https://your-domain.com/auth/callback' authorizeUrl: 'https://sso.example.com/application/o/authorize/' tokenUrl: 'https://sso.example.com/application/o/token/' userinfoUrl: 'https://sso.example.com/application/o/userinfo/' adminGroups: ['admin', 'superadmin', 'moderator'] # optional
Configuration parameters
| Parameter | Required | Type | Description |
|---|---|---|---|
clientId |
✅ Yes | string | OAuth2 client ID from your SSO provider |
clientSecret |
✅ Yes | string | OAuth2 client secret |
redirectUri |
✅ Yes | string | Callback URL after authentication (must be registered in SSO) |
authorizeUrl |
✅ Yes | string | OAuth2 authorization endpoint URL |
tokenUrl |
✅ Yes | string | OAuth2 token endpoint URL |
userinfoUrl |
✅ Yes | string | OAuth2 user info endpoint URL |
adminGroups |
❌ No | string[] | List of groups that grant admin access (default: see below) |
Default admin groups:
adminsuperadminadministratorsmoderatorsuperadmin-websauthentik admins
Environment variables
Secrets can be read from environment variables:
sso: clientId: %env.SSO_CLIENT_ID% clientSecret: %env.SSO_CLIENT_SECRET% redirectUri: %env.SSO_REDIRECT_URI% authorizeUrl: %env.SSO_AUTHORIZE_URL% tokenUrl: %env.SSO_TOKEN_URL% userinfoUrl: %env.SSO_USERINFO_URL%
Usage
Basic authentication flow
Create an authentication presenter:
<?php declare(strict_types=1); namespace App\Presenters; use Nette\Application\UI\Presenter; use NksHub\NetteSso\OAuth2\SsoProvider; use NksHub\NetteSso\Exceptions\AuthenticationFailedException; final class AuthPresenter extends Presenter { public function __construct( private readonly SsoProvider $ssoProvider, ) { parent::__construct(); } /** * Step 1: Redirect to SSO login page */ public function actionLogin(): void { $authUrl = $this->ssoProvider->getAuthorizationUrl(); $this->redirectUrl($authUrl); } /** * Step 2: Handle OAuth2 callback */ public function actionCallback(): void { $code = $this->getParameter('code'); $state = $this->getParameter('state'); if (!$code || !$state) { $this->flashMessage('Invalid login request', 'error'); $this->redirect('Homepage:'); } try { // Authenticate user via SSO $result = $this->ssoProvider->authenticate($code, $state); $userData = $result['user']; // Check admin access if (!$this->ssoProvider->hasAdminAccess($userData)) { $this->flashMessage('You are not allowed to access the administration', 'error'); $this->redirect('Homepage:'); } // Create Nette Identity $identity = $this->ssoProvider->createIdentity($userData); // Log user in $this->getUser()->login($identity); $this->flashMessage('Logged in as ' . $identity->getData()['name'], 'success'); $this->redirect('Admin:Dashboard:'); } catch (AuthenticationFailedException $e) { $this->flashMessage('Login failed: ' . $e->getMessage(), 'error'); $this->redirect('Homepage:'); } } /** * Logout */ public function actionLogout(): void { $this->getUser()->logout(true); $this->flashMessage('Logged out', 'info'); $this->redirect('Homepage:'); } }
Custom role mapping
To take the role from your own database instead of the groups:
// Get user from database $dbUser = $this->userRepository->findBySsoId($userData['sub']); // Create identity with custom role $identity = $this->ssoProvider->createIdentity( $userData, $dbUser?->role ?? 'viewer' // Override role from database );
Checking admin access
$userData = $result['user']; if ($this->ssoProvider->hasAdminAccess($userData)) { // User has admin access }
Getting configured admin groups
$adminGroups = $this->ssoProvider->getAdminGroups(); // Returns: ['admin', 'superadmin', 'moderator', ...]
OAuth2 flow diagram
┌─────────┐ ┌──────────┐ ┌─────────┐
│ Browser │ │ Your App │ │ SSO │
└────┬────┘ └────┬─────┘ └────┬────┘
│ │ │
│ 1. GET /auth/login │ │
├─────────────────────────>│ │
│ │ │
│ 2. Redirect to SSO │ │
│ + state token │ │
│<─────────────────────────┤ │
│ │ │
│ 3. GET authorize?state=... │
├──────────────────────────────────────────────────────>│
│ │ │
│ 4. User login form │ │
│<──────────────────────────────────────────────────────┤
│ │ │
│ 5. Submit credentials │ │
├──────────────────────────────────────────────────────>│
│ │ │
│ 6. Redirect to callback │ │
│ + code + state │ │
│<──────────────────────────────────────────────────────┤
│ │ │
│ 7. GET /auth/callback?code=...&state=... │
├─────────────────────────>│ │
│ │ │
│ │ 8. Validate state │
│ │ (CSRF protection) │
│ │ │
│ │ 9. POST /token │
│ │ (exchange code) │
│ ├──────────────────────────>│
│ │ │
│ │ 10. Access token │
│ │<──────────────────────────┤
│ │ │
│ │ 11. GET /userinfo │
│ ├──────────────────────────>│
│ │ │
│ │ 12. User data + groups │
│ │<──────────────────────────┤
│ │ │
│ │ 13. Check admin access │
│ │ Create Identity │
│ │ Login user │
│ │ │
│ 14. Redirect to admin │ │
│<─────────────────────────┤ │
│ │ │
Security considerations
CSRF protection
The extension checks the OAuth2 state parameter:
- When generating the authorization URL, a random state token is created
- The state token is stored in the user's session
- After OAuth2 callback, the state parameter is validated against the session
- If validation fails,
AuthenticationFailedExceptionis thrown
State token validation
try { $result = $this->ssoProvider->authenticate($code, $state); } catch (AuthenticationFailedException $e) { // Invalid state token (potential CSRF attack) // Handle error appropriately }
Configuration checklist
DO:
- ✅ Store secrets in environment variables
- ✅ Use HTTPS for redirect URIs
- ✅ Validate user groups before granting access
DON'T:
- ❌ Commit secrets to version control
- ❌ Use HTTP for redirect URIs in production
- ❌ Trust OAuth2 data without validation
- ❌ Expose detailed error messages to users
Role mapping
OAuth2 groups map to Nette roles in this priority order:
- Superadmin - Groups:
superadmin,superadmin-webs - Admin - Groups:
admin,administrators,authentik admins - Moderator - Groups:
moderator - Viewer - Default fallback role
Custom role
Pass the role as the second argument of createIdentity():
// From database $dbRole = $userRepository->getRoleForSsoId($userData['sub']); $identity = $this->ssoProvider->createIdentity($userData, $dbRole);
Identity structure
The created Nette\Security\SimpleIdentity contains:
- ID: OAuth2
sub(subject) - unique user identifier - Role: Detected or overridden role
- Data:
email- User email addressname- Display nameusername- Preferred usernamepicture- Profile picture URLgroups- Array of OAuth2 groupssso_provider- Always'authentik'
$identity = $this->ssoProvider->createIdentity($userData); echo $identity->getId(); // OAuth2 sub echo $identity->getRoles()[0]; // 'admin' echo $identity->getData()['email']; // 'user@example.com' echo $identity->getData()['name']; // 'John Doe' echo $identity->getData()['groups']; // ['admin', 'users']
Troubleshooting
Error: "Invalid state token (CSRF protection failed)"
Cause: State token mismatch between session and OAuth2 callback.
Solutions:
- Check if sessions are working correctly
- Verify that cookies are enabled
- Ensure
redirectUrimatches exactly in SSO provider settings - Check if user's browser allows third-party cookies
Error: "Failed to exchange authorization code for access token"
Cause: Invalid OAuth2 credentials or configuration.
Solutions:
- Verify
clientIdandclientSecretare correct - Check if
redirectUriis registered in SSO provider - Ensure
tokenUrlis accessible from your server - Check server logs for detailed error messages
Error: "Failed to retrieve user info"
Cause: Invalid access token or userinfo endpoint configuration.
Solutions:
- Verify
userinfoUrlis correct - Check if access token has required scopes (
openid,email,profile,groups) - Ensure userinfo endpoint is accessible
Users without admin groups can't access admin area
This is intended: only users in a group listed in adminGroups pass hasAdminAccess().
Solutions:
- Add user to admin group in SSO provider (Authentik)
- Add user's group to
adminGroupsconfiguration - Implement custom role mapping from database
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
License
MIT License — see LICENSE for details.
Made with ❤️ by NKS Hub