Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ The JWKS endpoint (`/ndi/jwks`) is always registered as it is shared across all

- **Login route returns JSON**: The authentication endpoint now returns `{ "redirect_url": "..." }` JSON instead of performing a server-side redirect. Clients must `fetch` the endpoint (with same-origin credentials) and navigate to the returned URL.
- **Session-backed routes required**: All routes must be behind session middleware (`web` group) for DPoP key storage, PKCE verifiers, and CSRF state verification.
- **`SingPassUser` model changes**: The `sub` claim is now a UUID (not a composite NRIC/UUID string). NRIC is available via `getNric()` only when the `user.identity` scope is requested; it returns `?string` instead of `string`.
- **`SingPassUser` model changes**: The `sub` claim is now a UUID (not a composite NRIC/UUID string). NRIC/FIN is available on the readonly **`nric`** property (from `sub_attributes.identity_number`) when the `user.identity` scope is requested (`?string`). The accessor **`getNric()`** is deprecated in favour of **`$nric`**.
- **MyInfo uses dedicated routes and config**: MyInfo flows use `/ndi/mi/initiate` and `/ndi/mi/callback` instead of sharing the SingPass login route. MyInfo has its own config file (`config/myinfo.php`) with separate client credentials (`MYINFO_CLIENT_ID` / `MYINFO_REDIRECT_URI`).
- **Discovery must include PAR endpoint**: The OpenID discovery response must contain `pushed_authorization_request_endpoint`. Incomplete discovery responses throw `OpenIdDiscoveryException`.
- **Config split**: Configuration has been split from a single `singpass-login.php` into four files (`ndi.php`, `singpass-login.php`, `myinfo.php`, `corppass-login.php`). Several environment variables have been renamed — see the "Config Split & Validation" section above. Re-publish config after upgrading.
Expand All @@ -162,7 +162,7 @@ The JWKS endpoint (`/ndi/jwks`) is always registered as it is shared across all
3. **Update client-side code**: Replace any server-side redirects to the login endpoint with `fetch` + `window.location.assign(redirect_url)`.
4. **Update exception references**: Rename any caught exceptions per the table above.
5. **Update service references**: If you injected `SingPassLogin`, `SingPassLoginInterface`, or the facade, switch to `FapiAuthenticationService` / `FapiCallbackService` via dependency injection.
6. **Update listener**: If your listener accesses `getNric()`, add a null check — NRIC requires the `user.identity` scope and returns `?string`.
6. **Update listener**: Use **`$event->getSingPassUser()->nric`** for NRIC/FIN (add a null check — requires the `user.identity` scope). Avoid **`getNric()`**; it is deprecated.
7. **Review scopes**: Configure `login_scopes` in `singpass-login.php` and `available_scopes` in `myinfo.php` to match your application's needs.

---
Expand Down
17 changes: 10 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# SingPass-Login

[![Coverage](https://sonarcloud.io/api/project_badges/measure?project=Accredifysg_SingPass-Login&metric=coverage&token=11b8dd252687c701584068be55e47e5e432056c8)](https://sonarcloud.io/summary/new_code?id=Accredifysg_SingPass-Login)
![badge.svg](coverage/badge.svg) ![](https://img.shields.io/badge/PHPStan-level%20max-brightgreen.svg?style=flat)

PHP Laravel Package for **SingPass Login**, **MyInfo**, and **CorpPass**. The authorization flow follows **FAPI 2.0–style** integration: **Pushed Authorization Requests (PAR)** with **DPoP** on the PAR, token, and UserInfo calls, **PKCE**, and private-key **JWT client assertions**. Your OpenID Provider metadata (discovery) must expose a `pushed_authorization_request_endpoint`; the package validates this when caching discovery.

Expand Down Expand Up @@ -69,7 +69,7 @@ NDI_SIGNING_KID=
NDI_JWKS=
NDI_PRIVATE_JWKS=

# FAPI 2.0 / DPoP (optional; default algorithm is ES256)
# FAPI 2.0 / DPoP — ephemeral key algorithm for DPoP proofs (ES256, ES384, or ES512; default ES256)
NDI_DPOP_SIGNING_ALGORITHM=ES256

# Diagnostic logging (disabled by default)
Expand Down Expand Up @@ -155,8 +155,8 @@ The package registers the following routes under the `web` middleware group:
| `GET /ndi/jwks` | `GetJwksEndpointController` | `singpass.jwks` | Expose your application's JWKS (always active) |
| `GET /ndi/sp/login` | `SingPass\LoginController` | `singpass.login` | Initiate SingPass Login |
| `GET /ndi/sp/callback` | `SingPass\LoginCallbackController` | `singpass.callback` | Handle SingPass Login callback |
| `GET /ndi/mi/initiate` | `SingPass\MyInfoController` | `myinfo.login` | Initiate MyInfo flow |
| `GET /ndi/mi/callback` | `SingPass\MyInfoCallbackController` | `myinfo.callback` | Handle MyInfo callback |
| `GET /ndi/mi/initiate` | `MyInfo\MyInfoController` | `myinfo.login` | Initiate MyInfo flow |
| `GET /ndi/mi/callback` | `MyInfo\MyInfoCallbackController` | `myinfo.callback` | Handle MyInfo callback |
| `GET /ndi/cp/login` | `CorpPass\LoginController` | `corppass.login` | Initiate CorpPass Login |
| `GET /ndi/cp/callback` | `CorpPass\LoginCallbackController` | `corppass.callback` | Handle CorpPass callback |

Expand Down Expand Up @@ -190,13 +190,13 @@ await startSingPassLogin(['openid', 'name', 'email', 'mobileno']);

### Listener

If you published the default listener, edit it to map your user retrieval via NRIC:
If you published the default listener, edit it to map your user retrieval via NRIC. Read the NRIC/FIN from the readonly **`nric`** property (populated from `sub_attributes.identity_number` when the **`user.identity`** scope is requested). **`SingPassUser::getNric()` is deprecated** and will be removed in a future major release; migrate listeners to `$singPassUser->nric`.

```php
public function handle(SingPassSuccessfulLoginEvent $event): void
{
$singPassUser = $event->getSingPassUser();
$nric = $singPassUser->getNric();
$nric = $singPassUser->nric;

if (! $nric) {
// NRIC is only available when the 'user.identity' scope is requested.
Expand Down Expand Up @@ -240,6 +240,8 @@ await startMyInfo(['openid', 'name', 'email', 'mobileno', 'nationality', 'dob'])

The MyInfo callback controller calls the UserInfo endpoint (with DPoP) to retrieve the requested data and emits `MyInfoDataRetrievedEvent`. Internally, `FapiCallbackService` uses `shouldCallUserInfo()` with the provider's `loginScopes` to determine the correct path: if the access token contains only login scopes, the ID token path is taken; otherwise the UserInfo endpoint is called.

Scope comparison reads the access token as an unverified JWT and expects a standard three-part compact JWT whose payload JSON includes a string `scope` claim (space-separated scope values, per OIDC). If the token is not a JWT, the payload cannot be decoded, or `scope` is missing or not a string, `UserInfoRequestException` is thrown instead of assuming `openid` only, so malformed tokens fail visibly during callback processing.

### Handling MyInfo Data

```php
Expand Down Expand Up @@ -418,6 +420,7 @@ This is particularly useful for diagnosing session issues (mismatched session ID
- The old `SingPassLoginFacade` and `SingPassLoginInterface` have been removed. If you were calling `SingPassLogin::handleCallback()` directly, the logic is now internal to the callback controllers.
- Exceptions have been renamed: `SingPassGetEndpointException` → `AuthFlowException`, `SingPassAuthenticationErrorException` → `AuthenticationErrorException`, `SingPassTokenException` → `TokenExchangeException`, `SingPassJwksException` → `JwksException`.
- Services have been renamed: `SingPassJwtService` → `JwtService`, `GetSingPassTokenService` → `TokenExchangeService`, `GetSingPassJwksService` → `JwksService`.
- **`SingPassUser::getNric()`** is deprecated; use the readonly **`nric`** property on `SingPassUser` instead.

## Exceptions

Expand Down Expand Up @@ -459,6 +462,6 @@ use Accredifysg\SingPassLogin\Exceptions\UserInfoVerificationException;

### UserInfo Exceptions

- **`UserInfoRequestException`**: The UserInfo endpoint HTTP request failed.
- **`UserInfoRequestException`**: The UserInfo HTTP request failed, **or** the access token could not be inspected for scopes before choosing the ID token vs UserInfo path (invalid JWT shape, undecodable payload, missing or non-string `scope` claim). Callback handling treats these as hard failures rather than silently defaulting scopes.
- **`UserInfoDecryptionException`**: The UserInfo JWE token decryption failed.
- **`UserInfoVerificationException`**: The UserInfo JWS token verification failed.
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@
"mockery/mockery": "^1.6",
"fakerphp/faker": "^1.23",
"phpstan/phpstan": "^2.1",
"phpstan/phpstan-mockery": "^2.0"
"phpstan/phpstan-mockery": "^2.0",
"phpstan/phpstan-phpunit": "^2.0"
},
"extra": {
"laravel": {
Expand Down
64 changes: 60 additions & 4 deletions composer.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions config/myinfo.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

declare(strict_types=1);

use Accredifysg\SingPassLogin\Http\Controllers\SingPass\MyInfoCallbackController;
use Accredifysg\SingPassLogin\Http\Controllers\SingPass\MyInfoController;
use Accredifysg\SingPassLogin\Http\Controllers\MyInfo\MyInfoCallbackController;
use Accredifysg\SingPassLogin\Http\Controllers\MyInfo\MyInfoController;

return [
'client_id' => env('MYINFO_CLIENT_ID'),
Expand Down
14 changes: 7 additions & 7 deletions coverage/badge.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 3 additions & 2 deletions database/migrations/add_corppass_entity_id_to_users_table.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
declare(strict_types=1);

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

class AddCorppassEntityIdToUsers extends Migration
Expand All @@ -12,7 +13,7 @@ class AddCorppassEntityIdToUsers extends Migration
*/
public function up(): void
{
Schema::table('users', function ($table) {
Schema::table('users', function (Blueprint $table) {
$table->string('corppass_entity_id')->nullable();
});
}
Expand All @@ -22,7 +23,7 @@ public function up(): void
*/
public function down(): void
{
Schema::table('users', function ($table) {
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('corppass_entity_id');
});
}
Expand Down
5 changes: 3 additions & 2 deletions database/migrations/add_nric_to_users_table.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
declare(strict_types=1);

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

class AddNricToUsers extends Migration
Expand All @@ -12,7 +13,7 @@ class AddNricToUsers extends Migration
*/
public function up(): void
{
Schema::table('users', function ($table) {
Schema::table('users', function (Blueprint $table) {
$table->string('nric')->nullable();
});
}
Expand All @@ -22,7 +23,7 @@ public function up(): void
*/
public function down(): void
{
Schema::table('users', function ($table) {
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('nric');
});
}
Expand Down
10 changes: 7 additions & 3 deletions phpstan.neon
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
includes:
- vendor/phpstan/phpstan-mockery/extension.neon
- vendor/phpstan/phpstan-phpunit/extension.neon

parameters:
level: 8
level: max
treatPhpDocTypesAsCertain: false
ignoreErrors:
-
identifier: method.alreadyNarrowedType
paths:
- src
- tests
- database

- tests
22 changes: 15 additions & 7 deletions src/DTOs/OpenIdConfigurationDto.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace Accredifysg\SingPassLogin\DTOs;

use Accredifysg\SingPassLogin\Exceptions\OpenIdDiscoveryException;
use Accredifysg\SingPassLogin\Support\TypeNarrow;

readonly class OpenIdConfigurationDto
{
Expand Down Expand Up @@ -42,7 +43,8 @@ public static function fromDiscoveryResponse(object $response): self
$missing = [];

foreach ($required as $field) {
if (! isset($data[$field]) || $data[$field] === '') {
$value = $data[$field] ?? null;
if (! is_string($value) || $value === '') {
$missing[] = $field;
}
}
Expand All @@ -55,12 +57,18 @@ public static function fromDiscoveryResponse(object $response): self
}

return new self(
issuer: $data['issuer'],
authorizationEndpoint: $data['authorization_endpoint'],
tokenEndpoint: $data['token_endpoint'],
userinfoEndpoint: $data['userinfo_endpoint'],
jwksUri: $data['jwks_uri'],
pushedAuthorizationRequestEndpoint: $data['pushed_authorization_request_endpoint'],
issuer: TypeNarrow::nonEmptyString($data, 'issuer')
?? throw new OpenIdDiscoveryException(500, 'OpenID discovery response has invalid non-string field: issuer'),
authorizationEndpoint: TypeNarrow::nonEmptyString($data, 'authorization_endpoint')
?? throw new OpenIdDiscoveryException(500, 'OpenID discovery response has invalid non-string field: authorization_endpoint'),
tokenEndpoint: TypeNarrow::nonEmptyString($data, 'token_endpoint')
?? throw new OpenIdDiscoveryException(500, 'OpenID discovery response has invalid non-string field: token_endpoint'),
userinfoEndpoint: TypeNarrow::nonEmptyString($data, 'userinfo_endpoint')
?? throw new OpenIdDiscoveryException(500, 'OpenID discovery response has invalid non-string field: userinfo_endpoint'),
jwksUri: TypeNarrow::nonEmptyString($data, 'jwks_uri')
?? throw new OpenIdDiscoveryException(500, 'OpenID discovery response has invalid non-string field: jwks_uri'),
pushedAuthorizationRequestEndpoint: TypeNarrow::nonEmptyString($data, 'pushed_authorization_request_endpoint')
?? throw new OpenIdDiscoveryException(500, 'OpenID discovery response has invalid non-string field: pushed_authorization_request_endpoint'),
);
}
}
Loading
Loading