baha2odeh / yii2-oauth
Lightweight OAuth2 authorization server extension for Yii2
Requires
- php: >=8.2
- yiisoft/yii2: ~2.0
Requires (Dev)
- phpunit/phpunit: ^11.0
This package is auto-updated.
Last update: 2026-05-12 09:40:33 UTC
README
A lightweight, extensible OAuth2 authorization server extension for Yii2.
Features
- Authorization Code grant with optional PKCE (S256 and plain) and automatic consent reuse
- Client Credentials grant
- Password grant
- Refresh Token grant
- Token storage via ActiveRecord — no abstraction layers, pure Yii2 style
- Any model class is swappable from module config
- Custom grant types via a simple interface
- BearerTokenAuth filter — attach to any controller
- Standalone actions — attach token/authorize/userinfo to your own controllers
- Console commands for client and scope management
- Single database migration — SQLite, MySQL, PostgreSQL compatible
Requirements
- PHP 8.1+
- Yii2 2.0.54+
Installation
composer require baha2odeh/yii2-oauth
Run the migration:
php yii migrate --migrationPath=@vendor/baha2odeh/yii2-oauth/migrations
Quick Start
1. Configure the module
Add the module to your application config (config/web.php):
'bootstrap' => ['oauth'], 'modules' => [ 'oauth' => [ 'class' => \baha2odeh\yii2oauth\OAuthModule::class, 'userModelClass' => \app\models\User::class, // your user AR class ], ],
The module self-registers these URL rules automatically — no urlManager changes needed:
| Method | URL | Description |
|---|---|---|
GET |
/oauth/authorize |
Authorization consent page |
POST |
/oauth/authorize/approve |
User approves or denies consent |
POST |
/oauth/token |
Token exchange endpoint |
GET |
/oauth/userinfo |
Returns authenticated user claims |
2. Implement UserEntityInterface on your User model
use baha2odeh\yii2oauth\contracts\entities\UserEntityInterface; class User extends \yii\db\ActiveRecord implements UserEntityInterface { // Required by UserEntityInterface public function getIdentifier(): string { return (string) $this->id; } public function getClaims(): array { return [ 'email' => $this->email, 'name' => $this->username, ]; } // Required by the password grant public static function findByCredentials(string $username, string $password): ?static { $user = static::findOne(['username' => $username]); if ($user && \Yii::$app->security->validatePassword($password, $user->password_hash)) { return $user; } return null; } // Required by the /userinfo endpoint (standard Yii2 IdentityInterface) public static function findIdentity($id): ?static { return static::findOne($id); } }
3. Create a client
php yii oauth/client/create --name="My App" --redirect-uris="https://myapp.com/callback"
Output:
Client created successfully!
Client ID: a3f1c8d2e09b4561
Client Secret: 7f3a1b9c2d4e5f6a...
(Save the secret now — it cannot be retrieved again.)
4. Test the token endpoint
# Client Credentials curl -X POST https://your-app.com/oauth/token \ -u "a3f1c8d2e09b4561:7f3a1b9c2d4e5f6a..." \ -d "grant_type=client_credentials"
{
"access_token": "e3b0c44298fc1c149a...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": ""
}
Grant Types
Authorization Code (with optional PKCE)
The recommended grant for web and mobile apps.
Step 1 — Redirect the user
GET /oauth/authorize
?response_type=code
&client_id=YOUR_CLIENT_ID
&redirect_uri=https://your-app.com/callback
&scope=openid profile
&state=RANDOM_STATE
&code_challenge=PKCE_CHALLENGE (optional, required for public clients with require_pkce=1)
&code_challenge_method=S256 (S256 or plain, defaults to S256)
The user sees the consent page and approves or denies the request.
Automatic consent reuse: If the user has previously approved this client and a valid (non-revoked, non-expired) access token still exists for the same user, client, and scopes, the consent screen is skipped entirely and the user is redirected back immediately with a new authorization code.
On approval, the server redirects back:
https://your-app.com/callback?code=AUTH_CODE&state=RANDOM_STATE
Step 2 — Exchange the code for tokens
curl -X POST https://your-app.com/oauth/token \ -u "CLIENT_ID:CLIENT_SECRET" \ -d "grant_type=authorization_code" \ -d "code=AUTH_CODE" \ -d "redirect_uri=https://your-app.com/callback" \ -d "code_verifier=PKCE_VERIFIER" # if PKCE was used
{
"access_token": "...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "...",
"scope": "openid profile"
}
PKCE Code Challenge Generation (PHP example)
$verifier = bin2hex(random_bytes(32)); $challenge = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '='); // Store $verifier in session; send $challenge in the /authorize request
Client Credentials
For machine-to-machine access. Confidential clients only.
curl -X POST https://your-app.com/oauth/token \ -u "CLIENT_ID:CLIENT_SECRET" \ -d "grant_type=client_credentials" \ -d "scope=api:read"
Password
Directly exchange user credentials for tokens. Requires the userModelClass to implement findByCredentials().
curl -X POST https://your-app.com/oauth/token \ -u "CLIENT_ID:CLIENT_SECRET" \ -d "grant_type=password" \ -d "username=alice@example.com" \ -d "password=secret" \ -d "scope=profile"
Refresh Token
Exchange a refresh token for a new access + refresh token pair. Old tokens are revoked immediately.
curl -X POST https://your-app.com/oauth/token \ -u "CLIENT_ID:CLIENT_SECRET" \ -d "grant_type=refresh_token" \ -d "refresh_token=REFRESH_TOKEN" \ -d "scope=profile" # optional — must be a subset of the original scope
UserInfo Endpoint
Returns the authenticated user's claims. Requires a valid Bearer token.
curl https://your-app.com/oauth/userinfo \
-H "Authorization: Bearer ACCESS_TOKEN"
{
"sub": "42",
"email": "alice@example.com",
"name": "alice"
}
The claims come from UserEntityInterface::getClaims() on your user model.
Console Commands
Client Management
# Create a confidential client php yii oauth/client/create \ --name="My App" \ --redirect-uris="https://app.com/cb" \ --grant-types="authorization_code,refresh_token" \ --scopes="openid profile email" \ --confidential=1 # Create a public client (SPA/mobile) php yii oauth/client/create \ --name="My SPA" \ --redirect-uris="https://app.com/cb" \ --grant-types="authorization_code,refresh_token" \ --confidential=0 # List all clients php yii oauth/client/list # Deactivate (reversible) php yii oauth/client/deactivate CLIENT_ID # Re-activate php yii oauth/client/activate CLIENT_ID # Permanently delete (cascades tokens and auth codes) php yii oauth/client/delete CLIENT_ID # Rotate secret (prints new secret once) php yii oauth/client/reset-secret CLIENT_ID
Scope Management
# List registered scopes php yii oauth/client/scopes # Register a scope php yii oauth/client/add-scope openid "OpenID Connect" --default=1 php yii oauth/client/add-scope profile "User profile" php yii oauth/client/add-scope email "Email address"
Maintenance
# Purge expired tokens (run via cron)
php yii oauth/client/purge-tokens
Recommended cron entry:
0 * * * * /path/to/yii oauth/client/purge-tokens >> /dev/null 2>&1
Protecting Your Routes
Using the BearerTokenAuth filter
Attach the filter to any controller or action:
use baha2odeh\yii2oauth\filters\BearerTokenAuth; class ApiController extends \yii\rest\Controller { public function behaviors(): array { return array_merge(parent::behaviors(), [ 'oauth' => [ 'class' => BearerTokenAuth::class, 'moduleId' => 'oauth', // matches your module key, default 'oauth' 'optional' => false, // true = let unauthenticated requests through ], ]); } public function actionProfile(): array { // Access the validated token $token = \Yii::$app->params['oauth.token']; return [ 'user_id' => $token->getUserId(), 'scopes' => $token->getScopes(), ]; } }
When a token is invalid or missing, the filter returns:
HTTP 401 WWW-Authenticate: Bearer realm="oauth" {"error": "invalid_token", "error_description": "..."}
Attaching Actions to Your Own Controllers
Instead of the default controllers, you can attach the standalone actions to any existing controller:
use baha2odeh\yii2oauth\actions\AuthorizeAction; use baha2odeh\yii2oauth\actions\TokenAction; use baha2odeh\yii2oauth\actions\UserinfoAction; use baha2odeh\yii2oauth\filters\BearerTokenAuth; class OAuthController extends \yii\web\Controller { public $enableCsrfValidation = false; // required for token endpoint public function behaviors(): array { return [ 'bearer' => [ 'class' => BearerTokenAuth::class, 'only' => ['userinfo'], ], ]; } public function actions(): array { return [ 'token' => TokenAction::class, 'authorize' => [ 'class' => AuthorizeAction::class, 'viewFile' => '@app/views/oauth/authorize', // override the consent view 'loginUrl' => ['/site/login'], // redirect here if user is not logged in ], 'userinfo' => UserinfoAction::class, ]; } }
AuthorizeAction properties:
| Property | Type | Default | Description |
|---|---|---|---|
viewFile |
string |
@baha2odeh/yii2oauth/views/authorize/index |
Path to the consent view |
loginUrl |
string|array |
['/site/login'] |
Where to redirect unauthenticated users |
moduleId |
string |
oauth |
The OAuth module ID in your app config |
sessionKey |
string |
oauth_auth_request |
Session key for persisting the auth request |
Full Configuration Reference
'modules' => [ 'oauth' => [ 'class' => \baha2odeh\yii2oauth\OAuthModule::class, // ---- Required ---- 'userModelClass' => \app\models\User::class, // ---- Model overrides (swap any AR class) ---- 'clientModelClass' => \app\models\OAuthClient::class, 'accessTokenModelClass' => \baha2odeh\yii2oauth\models\OAuthAccessToken::class, 'refreshTokenModelClass' => \baha2odeh\yii2oauth\models\OAuthRefreshToken::class, 'authCodeModelClass' => \baha2odeh\yii2oauth\models\OAuthAuthCode::class, 'scopeModelClass' => \baha2odeh\yii2oauth\models\OAuthScope::class, // ---- Enabled grants ---- 'enabledGrants' => [ 'authorization_code', 'client_credentials', 'password', 'refresh_token', ], // ---- Token TTLs (seconds) ---- 'accessTokenTtl' => 3600, // 1 hour 'refreshTokenTtl' => 2592000, // 30 days 'authCodeTtl' => 600, // 10 minutes // ---- Token entropy ---- 'tokenBytes' => 32, // 32 bytes → 64-char hex token // ---- PKCE ---- // false: PKCE is optional globally; per-client require_pkce column overrides // true: PKCE required for every client 'requirePkce' => false, 'allowedPkceMethods' => ['S256', 'plain'], // ---- Custom grants ---- 'customGrants' => [ 'device_code' => \app\oauth\DeviceCodeGrant::class, ], ], ],
Customization
Extending a Model
namespace app\models; use baha2odeh\yii2oauth\models\OAuthClient as BaseClient; class OAuthClient extends BaseClient { // Add extra scopes validation, multi-tenancy, etc. public static function validateCredentials(string $clientId, ?string $secret, string $grantType): ?static { $client = parent::validateCredentials($clientId, $secret, $grantType); // Additional checks (e.g. tenant isolation) return $client; } }
Register the extended model:
'clientModelClass' => \app\models\OAuthClient::class,
Writing a Custom Grant
namespace app\oauth; use baha2odeh\yii2oauth\grants\AbstractGrant; use yii\web\Request; class DeviceCodeGrant extends AbstractGrant { public function getIdentifier(): string { return 'urn:ietf:params:oauth:grant-type:device_code'; } public function respondToAccessTokenRequest(Request $request): array { $client = $this->validateClient($request); $deviceCode = $request->getBodyParam('device_code', ''); // ... your device code validation logic ... $scopes = $this->validateScopes('', $client); $token = $this->issueAccessToken($client, null, $scopes, $this->accessTokenTtl); return $this->buildTokenResponse($token, null, $this->accessTokenTtl); } }
Register it in config:
'customGrants' => [ 'urn:ietf:params:oauth:grant-type:device_code' => \app\oauth\DeviceCodeGrant::class, ],
Overriding the Consent View
Create a view at any path and set it on the action:
// In your controller 'authorize' => [ 'class' => \baha2odeh\yii2oauth\actions\AuthorizeAction::class, 'viewFile' => '@app/views/oauth/consent', 'loginUrl' => ['/site/login'], ],
Consent Reuse (Auto-Approve)
By default, AuthorizeAction skips the consent screen when the user already has a valid access token for the same client and scopes. This means:
- First authorization: user sees the consent page and must approve.
- Subsequent authorizations: if a non-revoked, non-expired token exists covering the requested scopes, the user is redirected back immediately with a new auth code — no consent page shown.
- Token expires or is revoked: consent page is shown again.
This is the standard behavior used by most OAuth2 providers (Google, GitHub, etc.).
If you need to force consent every time (e.g. for sensitive scopes), you can override AuthorizeAction and bypass the auto-approve check:
use baha2odeh\yii2oauth\actions\AuthorizeAction; class StrictAuthorizeAction extends AuthorizeAction { // Override to always show consent — skip automatic approval // by not calling parent and re-implementing handleAuthorize without the token check }
Available variables in the view:
| Variable | Type | Description |
|---|---|---|
$client |
ClientEntityInterface |
The requesting client |
$scopes |
array<string, string> |
identifier => description pairs |
$state |
string|null |
The state parameter from the request |
$action |
AuthorizeAction |
The action instance (access moduleId) |
Minimal consent view example:
<?php use yii\helpers\Html; ?> <h2><?= Html::encode($client->getName()) ?> wants access</h2> <ul> <?php foreach ($scopes as $id => $desc): ?> <li><?= Html::encode($desc) ?></li> <?php endforeach; ?> </ul> <?php $form = \yii\widgets\ActiveForm::begin(); ?> <?= Html::hiddenInput('state', $state) ?> <?= Html::submitButton('Allow', ['name' => 'approved', 'value' => '1']) ?> <?= Html::submitButton('Deny', ['name' => 'approved', 'value' => '0']) ?> <?php \yii\widgets\ActiveForm::end(); ?>
Database Tables
| Table | Description |
|---|---|
oauth_clients |
Registered OAuth2 clients |
oauth_scopes |
Available permission scopes |
oauth_access_tokens |
Issued access tokens (SHA-256 hashed) |
oauth_refresh_tokens |
Refresh tokens linked to access tokens |
oauth_auth_codes |
Authorization codes (single-use) |
Token values are never stored in plain text. The SHA-256 hash is stored; the raw value is returned to the client once and never held server-side.
Security Notes
- Client secrets are stored as bcrypt hashes (
Yii::$app->security->generatePasswordHash). - Tokens are stored as SHA-256 hashes. Compromise of the database does not expose usable tokens.
- Authorization codes are single-use and expire in 10 minutes by default.
- PKCE is strongly recommended for public clients (SPAs, mobile apps). Enable it per-client with
require_pkce = 1or globally with'requirePkce' => true. - The token endpoint sets
Cache-Control: no-storeandPragma: no-cacheon every response. - CSRF validation is disabled only on the token endpoint (OAuth2 spec requirement).
Running Tests
composer install vendor/bin/phpunit
Tests use an in-memory SQLite database — no external dependencies required.
License
MIT © Baha2Odeh