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
10 changes: 5 additions & 5 deletions .github/workflows/check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,11 @@ jobs:
strategy:
fail-fast: false
matrix:
php-versions: ['7.4', '8.0', '8.1', '8.2', '8.3']
php-versions: ['8.0', '8.1', '8.2', '8.3', '8.4']
steps:

- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@v4

- name: Setup PHP
uses: shivammathur/setup-php@v2
Expand All @@ -33,7 +33,7 @@ jobs:
echo "::set-output name=dir::$(composer config cache-files-dir)"

- name: Composer cache
uses: actions/cache@v3
uses: actions/cache@v4
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer
Expand All @@ -56,12 +56,12 @@ jobs:
run: composer run phpunit -- --teamcity

- name: Set up Go
uses: actions/setup-go@v3
uses: actions/setup-go@v5
with:
go-version: ^1.16

- name: Go cache
uses: actions/cache@v3
uses: actions/cache@v4
with:
path: ~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
Expand Down
2 changes: 1 addition & 1 deletion LICENSE
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
Copyright (C) 2021 Angry Bytes
Copyright (C) 2025 Angry Bytes

Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
Expand Down
7 changes: 3 additions & 4 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@
"authors": [
{
"name": "Stéphan Kochen",
"email": "stephan@kochen.nl"
"email": "mail@stephank.nl"
}
],
"scripts": {
"php-cs-fixer": "php-cs-fixer fix",
"phpstan": "phpstan analyse -l max -c phpstan.neon src/",
"phpstan": "phpstan analyse -l max src/",
"phpunit": "phpunit"
},
"autoload": {
Expand All @@ -20,14 +20,13 @@
}
},
"require": {
"fgrosse/phpasn1": "^2.3.1",
"lcobucci/clock": "^2.0.0 || ^3.0.0",
"lcobucci/jwt": "^4.1.0 || ^5.0.0",
"guzzlehttp/guzzle": "^7.2.0"
},
"require-dev": {
"phpunit/phpunit": "^9.5 || ^10.4.2",
"phpstan/phpstan": "^1.2.0",
"phpstan/phpstan": "^2.1.5",
"friendsofphp/php-cs-fixer": "^3.14"
}
}
12 changes: 0 additions & 12 deletions phpstan.neon

This file was deleted.

8 changes: 4 additions & 4 deletions src/AbstractStore.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,14 @@ abstract class AbstractStore implements StoreInterface
/**
* Lifespan of a nonce.
*
* @var float
* @var int
*/
public $nonceTtl = 15 * 60;

/**
* Minimum time to cache a HTTP response.
*
* @var float
* @var int
*/
public $cacheMinTtl = 60 * 60;

Expand Down Expand Up @@ -57,9 +57,9 @@ public function generateNonce(string $email): string
*
* @param string $url the URL to fetch
*
* @return \stdClass an object with `ttl` and `data` properties
* @return object{data: \stdClass, ttl: int}
*/
public function fetch(string $url): \stdClass
public function fetch(string $url): object
{
$res = $this->guzzle->get($url);

Expand Down
52 changes: 12 additions & 40 deletions src/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ public static function normalize(string $email): string
assert(defined('MB_CASE_FOLD') && function_exists('idn_to_ascii'));

$localEnd = strrpos($email, '@');
if (false === $localEnd) {
if (false === $localEnd || $localEnd + 1 === strlen($email)) {
return '';
}

Expand Down Expand Up @@ -101,7 +101,7 @@ public static function normalize(string $email): string
*
* @return string URL to redirect the browser to
*/
public function authenticate(string $email, string $state = null): string
public function authenticate(string $email, ?string $state = null): string
{
$authEndpoint = $this->fetchDiscovery()->authorization_endpoint ?? null;
if (!is_string($authEndpoint)) {
Expand Down Expand Up @@ -161,19 +161,23 @@ public function verify(string $token): string
}

// Find the matching public key, and verify the signature.
$publicKey = null;
$publicKey = '';
foreach ($keysDoc->keys as $key) {
if ($key instanceof \stdClass
&& isset($key->alg) && 'RS256' === $key->alg
&& isset($key->kid) && $key->kid === $kid
&& isset($key->n) && isset($key->e)) {
$publicKey = self::parseJwk($key);
&& isset($key->alg) && 'RS256' === $key->alg
&& isset($key->kid) && $key->kid === $kid
) {
try {
$publicKey = JWK::toPem($key);
} catch (\Exception) {
}
break;
}
}
if (null === $publicKey) {
if ('' === $publicKey) {
throw new \Exception('Cannot find the public key used to sign the token');
}
$publicKey = JwtSigner\Key\InMemory::plainText($publicKey);

// Validate the token claims.
$clock = \Lcobucci\Clock\SystemClock::fromUTC();
Expand Down Expand Up @@ -228,28 +232,6 @@ private function fetchDiscovery(): \stdClass
return $this->store->fetchCached('discovery', $discoveryUrl);
}

/**
* Parse a JWK into a PEM public key.
*/
private static function parseJwk(\stdClass $jwk): JwtSigner\Key
{
$n = gmp_init(bin2hex(self::decodeBase64Url($jwk->n)), 16);
$e = gmp_init(bin2hex(self::decodeBase64Url($jwk->e)), 16);

$seq = new \FG\ASN1\Universal\Sequence();
$seq->addChild(new \FG\ASN1\Universal\Integer(gmp_strval($n)));
$seq->addChild(new \FG\ASN1\Universal\Integer(gmp_strval($e)));
$pkey = new \FG\X509\PublicKey(bin2hex($seq->getBinary()));

$encoded = base64_encode($pkey->getBinary());

return JwtSigner\Key\InMemory::plainText(
"-----BEGIN PUBLIC KEY-----\n".
chunk_split($encoded, 64, "\n").
"-----END PUBLIC KEY-----\n"
);
}

/**
* Get the origin for a URL.
*/
Expand Down Expand Up @@ -281,14 +263,4 @@ private static function getOrigin(string $url): string

return $res;
}

private static function decodeBase64Url(string $input): string
{
$output = base64_decode(strtr($input, '-_', '+/'), true);
if (false === $output) {
throw new \Exception('Invalid base64');
}

return $output;
}
}
90 changes: 90 additions & 0 deletions src/DER.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
<?php

namespace Portier\Client;

/**
* Limited DER encoding functions.
*/
final class DER
{
private const BIT_ID_CONSTRUCTED = 0x1 << 5;

public const ID_INTEGER = 2;
public const ID_BIT_STRING = 3;
public const ID_OCTET_STRING = 4;
public const ID_OBJECT_ID = 6;
public const ID_SEQUENCE = 16 | self::BIT_ID_CONSTRUCTED;

public const NULL = "\x05\0";

private function __construct()
{
}

/**
* Encodes a value in DER identifier-length-contents format.
*
* @param self::ID_* $id
*/
public static function encodeValue(int $id, string $content): string
{
// Assumption: we don't need long form.
$prefix = chr($id);

$len = strlen($content);
if ($len < 128) {
$prefix .= chr($len);
} else {
// Assumption: we never encode anything larger than 2^31
$enc = ltrim(pack('N', $len), "\0");
$prefix .= chr(0x80 | strlen($enc));
$prefix .= $enc;
}

return $prefix.$content;
}

/**
* Encode an integer to base128.
*/
public static function encodeBase128(int $num): string
{
$result = chr($num & 0x7F);
$num >>= 7;
while ($num > 0) {
$result .= chr(($num & 0x7F) | 0x80);
$num >>= 7;
}

return strrev($result);
}

/**
* Encode a sequence of values.
*/
public static function encodeSequence(string ...$values): string
{
return self::encodeValue(self::ID_SEQUENCE, implode('', $values));
}

/**
* Encode an object identifier.
*/
public static function encodeOid(int ...$values): string
{
$bin = '';
foreach ($values as $value) {
$bin .= self::encodeBase128($value);
}

return self::encodeValue(self::ID_OBJECT_ID, $bin);
}

/**
* Encode some data as a bit string.
*/
public static function encodeBitString(string $data): string
{
return self::encodeValue(self::ID_BIT_STRING, "\0".$data);
}
}
Loading