1: <?php
2: /*
3: * SimpleID
4: *
5: * Copyright (C) Kelvin Mo 2012-2025
6: *
7: * This program is free software; you can redistribute it and/or
8: * modify it under the terms of the GNU General Public
9: * License as published by the Free Software Foundation; either
10: * version 2 of the License, or (at your option) any later version.
11: *
12: * This program is distributed in the hope that it will be useful,
13: * but WITHOUT ANY WARRANTY; without even the implied warranty of
14: * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
15: * General Public License for more details.
16: *
17: * You should have received a copy of the GNU General Public
18: * License along with this program; if not, write to the Free
19: * Software Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
20: *
21: */
22:
23: namespace SimpleID\Protocols\Connect;
24:
25: use \Base;
26: use SimpleID\Crypt\Random;
27: use SimpleID\Util\ArrayWrapper;
28: use SimpleJWT\JWT;
29: use SimpleJWT\JWE;
30: use SimpleJWT\Crypt\AlgorithmFactory;
31: use SimpleJWT\Crypt\CryptException;
32:
33: /**
34: * A class representing a JWT response, which can be signed and/or
35: * encrypted.
36: *
37: * The exact format of the response is determined by the configuration
38: * of the specified client.
39: *
40: * This class is a subclass of {@link ArrayWrapper}. Token claims
41: * are stored in {@link ArrayWrapper->$container} and are accessed
42: * using array syntax. Token headers are set using the {@link setHeaders()}
43: * and {@link setHeader()} methods.
44: */
45: class JOSEResponse extends ArrayWrapper {
46: /** @var string */
47: protected $issuer;
48:
49: /** @var \SimpleID\Protocols\OAuth\OAuthClient */
50: protected $client;
51:
52: /** @var string */
53: protected $signed_response_alg = null;
54:
55: /** @var string */
56: protected $encrypted_response_alg = null;
57:
58: /** @var string */
59: protected $encrypted_response_enc = null;
60:
61: /** @var array<string, string> */
62: protected $headers = [];
63:
64: /**
65: * Creates a response.
66: *
67: * `$path_prefix` is used to construct paths to the configuration variables, which
68: * are then accessed using {@link ArrayWrapper::pathGet()}. For example, if
69: * `$path_prefix` is `connect.userinfo`, then this will configure the JOSE
70: * algorithms using the following paths:
71: *
72: * - `connect.userinfo_signed_response_alg`
73: * - `connect.userinfo_encrypted_response_alg`
74: * - `connect.userinfo_encrypted_response_enc`
75: *
76: * @param string $issuer the issuer ID
77: * @param \SimpleID\Protocols\OAuth\OAuthClient $client the OAuth client to which the response
78: * will be sent
79: * @param string $path_prefix the prefix from which paths will be formed and passed
80: * to {@link ArrayWrapper::get()} to get the client configuration
81: * @param array<string, mixed> $data the initial claims
82: * @param string $default_signed_response_alg the default `_signed_response_alg` value
83: * if the client configuration is not found
84: */
85: function __construct($issuer, $client, $path_prefix, $data = [], $default_signed_response_alg = null) {
86: parent::__construct($data);
87: $this->issuer = $issuer;
88: $this->client = $client;
89:
90: if ($this->client->exists($path_prefix . '_signed_response_alg')) {
91: $this->signed_response_alg = $this->client->get($path_prefix . '_signed_response_alg');
92: } elseif ($default_signed_response_alg != null) {
93: $this->signed_response_alg = $default_signed_response_alg;
94: }
95:
96: if ($this->client->exists($path_prefix . '_encrypted_response_alg')) {
97: $this->encrypted_response_alg = $this->client->get($path_prefix . '_encrypted_response_alg');
98: if ($this->client->exists($path_prefix . '_encrypted_response_enc')) {
99: $this->encrypted_response_enc = $this->client->get($path_prefix . '_encrypted_response_enc');
100: } else {
101: $this->encrypted_response_enc = 'A128CBC-HS256';
102: }
103: }
104: }
105:
106: /**
107: * Sets the headers for the JWT, overwriting all existing headers.
108: *
109: * @param array<string, string> $headers the headers to set
110: * @return void
111: */
112: function setHeaders($headers) {
113: $this->headers = $headers;
114: }
115:
116: /**
117: * Sets a specified header for the JWT.
118: *
119: * @param string $header the header to set
120: * @param string $value the header value
121: * @return void
122: */
123: function setHeader($header, $value) {
124: $this->headers[$header] = $value;
125: }
126:
127: /**
128: * Sets a claim to be the short hash of a particular value.
129: *
130: * The OpenID Connect specification requires, in certain circumstances, the
131: * short hash of OAuth response parameters to be included in an ID token.
132: * This function calculates the short hash of the OAuth response parameter
133: * (specified in `$value`) and places it as a claim with a name specified
134: * by `$claim`. Normally `$claim` will be `c_hash` or `at_hash` and `$value`
135: * will be the authorisation code or access token respectively.
136: *
137: * The short hash is the left-most half of the hash, with the hash algorithm
138: * being the one underlying the signature algorithm. For instance, if the signature
139: * algorithm is RS256, the underlying hash algorithm is SHA-256, and this function
140: * will return the encoded value of the left-most 128 bits of the SHA-256 hash.
141: *
142: * @param string $claim the name of the claim
143: * @param string $value the value over which the short hash to be calculated
144: * @return void
145: */
146: function setShortHashClaim($claim, $value) {
147: $alg = ($this->signed_response_alg) ? $this->signed_response_alg : 'HS256';
148:
149: try {
150: /** @var \SimpleJWT\Crypt\Signature\SignatureAlgorithm $signer */
151: $signer = AlgorithmFactory::create($alg);
152: $this->container[$claim] = $signer->shortHash($value);
153: } catch (\UnexpectedValueException $e) {
154: // Do nothing
155: }
156: }
157:
158: /**
159: * Renders the response.
160: *
161: * This function calls the {@link buildJOSE()} method to get the response
162: * body, then renders it with the appropriate HTTP headers.
163: *
164: * @param \SimpleJWT\Keys\KeySet $set the key set to be passed to the
165: * {@link buildJOSE()} method.
166: * @return void
167: */
168: function render($set = null) {
169: $jose = $this->buildJOSE($set);
170:
171: if ($jose == null) {
172: $f3 = Base::instance();
173: $f3->status(500);
174: } else {
175: header('Content-Type: application/' . $this->getType());
176: print $this->buildJOSE($set);
177: }
178: }
179:
180: /**
181: * Builds the JOSE response. This will return one of the following:
182: *
183: * - A JSON encoded string, if {@link $signed_response_alg} and
184: * {@link $encrypted_response_alg} are both null
185: * - A signed JWT (JWS), if {@link $signed_response_alg} is set
186: * - A JWE containing a nested JWT, if both {@link $signed_response_alg}
187: * and {@link $encrypted_response_alg} are set
188: *
189: * @param \SimpleJWT\Keys\KeySet $set the key set used to sign and/or
190: * encrypt the token. If set to null, the default set of keys
191: * configured for the client and the server are loaded
192: * @return string|null the response body
193: */
194: function buildJOSE($set = null) {
195: $rand = new Random();
196: $typ = $this->getType();
197:
198: if ($typ == 'json') {
199: $json = json_encode($this->container);
200: if ($json == false) return null;
201: return $json;
202: }
203:
204: if ($set == null) {
205: $builder = new KeySetBuilder($this->client);
206: $set = $builder->addClientSecret()->addClientPublicKeys()->addServerPrivateKeys()->toKeySet();
207: }
208:
209: $headers = array_merge($this->headers, [ 'alg' => $this->signed_response_alg ]);
210: $claims = array_merge($this->container, [
211: 'iss' => $this->issuer,
212: 'aud' => $this->client->getStoreID(),
213: 'jti' => $rand->id()
214: ]);
215:
216: $jwt = new JWT($headers, $claims);
217: try {
218: $token = $jwt->encode($set);
219: } catch (CryptException $e) {
220: return null;
221: }
222:
223: if ($typ == 'jwt') return $token;
224:
225: $headers = [
226: 'alg' => $this->encrypted_response_alg,
227: 'enc' => $this->encrypted_response_enc,
228: 'cty' => 'JWT'
229: ];
230:
231: $jwe = new JWE($headers, $token);
232: try {
233: return $jwe->encrypt($set);
234: } catch (CryptException $e) {
235: return null;
236: }
237: }
238:
239: /**
240: * Determines the type of response body. This type can be appended
241: * to `application/` to form a proper MIME media type.
242: *
243: * @return string the type
244: */
245: protected function getType() {
246: if (($this->encrypted_response_enc != null) && ($this->encrypted_response_alg != null)) {
247: return 'jwe';
248: } elseif ($this->signed_response_alg != null) {
249: return 'jwt';
250: } else {
251: return 'json';
252: }
253: }
254: }
255: ?>
256: