Skip to content

Commit 2fc4b7d

Browse files
authored
Merge branch 'master' into fm/multigres-tests
2 parents ccd7ab5 + 983f59a commit 2fc4b7d

29 files changed

Lines changed: 1086 additions & 208 deletions

docs/saml_key_rotation.md

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
# SAML SP Key Rotation Runbook
2+
3+
Zero-downtime rotation of the SAML Service Provider signing (and optional encryption) key.
4+
5+
---
6+
7+
## Overview
8+
9+
GoTrue advertises its SP public key inside SAML metadata. Identity Providers (IdPs) cache this
10+
metadata and use the embedded certificate to verify SP-signed AuthnRequests and (when encryption
11+
is enabled) to encrypt assertions sent back to the SP.
12+
13+
A rotation therefore has two concerns:
14+
15+
1. **Signing** — IdPs must trust the new certificate before GoTrue starts signing with it.
16+
2. **Encryption** (only when `GOTRUE_SAML_ALLOW_ENCRYPTED_ASSERTIONS=true`) — GoTrue must be
17+
able to decrypt assertions that were encrypted with the *old* certificate while the IdP's
18+
cache still points to it.
19+
20+
Both concerns are handled automatically once you follow the steps below.
21+
22+
---
23+
24+
## Prerequisites
25+
26+
- Access to the GoTrue environment variables / secrets store.
27+
- Ability to trigger a rolling restart or redeploy of GoTrue.
28+
- `openssl` available locally (or equivalent).
29+
30+
---
31+
32+
## Step 1 — Generate the new key
33+
34+
```bash
35+
# Produces a PKCS#1 DER key encoded as standard Base64 (no line breaks).
36+
openssl genrsa 2048 | openssl rsa -outform DER | base64 | tr -d '\n'
37+
```
38+
39+
Store the output somewhere safe (secret manager, vault). This is the **new key** value.
40+
41+
> **Requirement:** RSA 2048 or larger, public exponent 65537 (the `openssl genrsa` default).
42+
43+
---
44+
45+
## Step 2 — Announce the new certificate (dual-key window)
46+
47+
Set the new key as the *next* key **without** touching the primary key:
48+
49+
```
50+
GOTRUE_SAML_PRIVATE_KEY=<current key — unchanged>
51+
GOTRUE_SAML_PRIVATE_KEY_NEXT=<new key from Step 1>
52+
```
53+
54+
Redeploy / restart GoTrue.
55+
56+
**What happens:**
57+
58+
- Both certificates appear in SP metadata under `<md:KeyDescriptor use="signing">`.
59+
- The primary certificate remains first, so IdPs that already trust it continue to work.
60+
- `Cache-Control` drops to `max-age=60` and the XML `cacheDuration` is set to `PT1H` so IdPs
61+
re-fetch metadata sooner.
62+
- The `/settings` endpoint returns `"saml_private_key_next_configured": true`.
63+
- If encrypted assertions are enabled, both certificates also appear as `use="encryption"`
64+
descriptors, and GoTrue will automatically retry decryption with the old key if the primary
65+
key fails.
66+
67+
**Verify:**
68+
69+
```bash
70+
curl -s https://<your-domain>/auth/v1/sso/saml/metadata \
71+
| xmllint --xpath 'count(//md:KeyDescriptor[@use="signing"])' \
72+
--noout - 2>/dev/null
73+
# Expected: 2
74+
```
75+
76+
---
77+
78+
## Step 3 — Wait for IdP caches to drain
79+
80+
IdPs must re-fetch metadata and import the new certificate before you promote it. The safe
81+
window is determined by the *largest* cache TTL among your IdPs.
82+
83+
**Minimum wait:** 1 hour (the `cacheDuration=PT1H` advertised in metadata).
84+
85+
For IdPs with longer cache windows or manual metadata import workflows, trigger a metadata
86+
refresh in their admin console before proceeding, or contact the IdP admin to confirm the new
87+
certificate is imported.
88+
89+
Confirm the new certificate is trusted by performing a test login with an affected IdP if
90+
possible.
91+
92+
---
93+
94+
## Step 4 — Promote the new key
95+
96+
Swap the values and remove `_NEXT`:
97+
98+
```
99+
GOTRUE_SAML_PRIVATE_KEY=<new key from Step 1>
100+
GOTRUE_SAML_PRIVATE_KEY_NEXT= # remove / clear
101+
```
102+
103+
Redeploy / restart GoTrue.
104+
105+
**What happens:**
106+
107+
- Metadata now advertises only the new certificate.
108+
- `Cache-Control` returns to `max-age=600`.
109+
- Signing switches to the new key immediately.
110+
- If encrypted assertions are enabled, GoTrue no longer attempts the fallback decryption with
111+
the old key (it is no longer configured).
112+
113+
**Verify:**
114+
115+
```bash
116+
curl -s https://<your-domain>/auth/v1/sso/saml/metadata \
117+
| xmllint --xpath 'count(//md:KeyDescriptor[@use="signing"])' \
118+
--noout - 2>/dev/null
119+
# Expected: 1
120+
121+
curl -s https://<your-domain>/auth/v1/settings \
122+
| jq '.saml_private_key_next_configured'
123+
# Expected: false
124+
```
125+
126+
Perform a test login to confirm end-to-end flow.
127+
128+
---
129+
130+
## Encrypted assertions — additional notes
131+
132+
When `GOTRUE_SAML_ALLOW_ENCRYPTED_ASSERTIONS=true`:
133+
134+
- During the dual-key window (Step 2), GoTrue accepts assertions encrypted with **either** the
135+
primary or the next (old) certificate. No action needed.
136+
- The IdP may send assertions encrypted with the old certificate for up to the cache window
137+
after Step 4. This is safe because the old key is gone from configuration and the IdP should
138+
have already switched to the new certificate. If any IdP still sends assertions encrypted with
139+
the old certificate after promotion, those assertions will fail. Contact the IdP admin to
140+
force a metadata refresh.
141+
142+
---
143+
144+
## Rollback
145+
146+
| Phase | How to rollback |
147+
|-------|----------------|
148+
| After Step 2 (dual-key deployed, not yet promoted) | Clear `GOTRUE_SAML_PRIVATE_KEY_NEXT` and redeploy. No key material was changed at IdPs. |
149+
| After Step 4 (new key promoted) | Restore the old key to `GOTRUE_SAML_PRIVATE_KEY`, set the new key in `GOTRUE_SAML_PRIVATE_KEY_NEXT`, redeploy. You are back to the dual-key window. Wait for IdPs to re-import the old certificate before relying on it. |
150+
151+
> Avoid skipping the dual-key window. Promoting a new key before IdPs have cached the new
152+
> certificate will break SP-initiated flows for the cache window duration.
153+
154+
---
155+
156+
## Quick reference
157+
158+
| Variable | Purpose |
159+
|----------|---------|
160+
| `GOTRUE_SAML_PRIVATE_KEY` | Active signing (and decryption) key. PKCS#1 DER, Base64-encoded. |
161+
| `GOTRUE_SAML_PRIVATE_KEY_NEXT` | Incoming key during rotation. Advertised in metadata; used as decryption fallback. Clear after promotion. |
162+
| `GOTRUE_SAML_ALLOW_ENCRYPTED_ASSERTIONS` | Enable encrypted assertion support. Both keys appear as `use="encryption"` descriptors when rotation is active. |

go.mod

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,21 @@ require (
3535

3636
require (
3737
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 // indirect
38+
github.com/aws/aws-sdk-go-v2 v1.41.7 // indirect
39+
github.com/aws/aws-sdk-go-v2/config v1.32.18 // indirect
40+
github.com/aws/aws-sdk-go-v2/credentials v1.19.17 // indirect
41+
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 // indirect
42+
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 // indirect
43+
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 // indirect
44+
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 // indirect
45+
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 // indirect
46+
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 // indirect
47+
github.com/aws/aws-sdk-go-v2/service/kms v1.52.0 // indirect
48+
github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 // indirect
49+
github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 // indirect
50+
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.0 // indirect
51+
github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 // indirect
52+
github.com/aws/smithy-go v1.25.1 // indirect
3853
github.com/bits-and-blooms/bitset v1.20.0 // indirect
3954
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
4055
github.com/consensys/gnark-crypto v0.18.1 // indirect

go.sum

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,36 @@ github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b h1:slYM766cy2nI3BwyR
1717
github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b/go.mod h1:1KcenG0jGWcpt8ov532z81sp/kMMUG485J2InIOyADM=
1818
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
1919
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
20+
github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8=
21+
github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc=
22+
github.com/aws/aws-sdk-go-v2/config v1.32.18 h1:Hcia46bxhGgF3BaSnG8nSNCWmqTK6bj9xN9/FJ3WK6Q=
23+
github.com/aws/aws-sdk-go-v2/config v1.32.18/go.mod h1:zEjCAYmxqDadH1WX8CdBvmLKhUEUVFgKRQG38zjDmrY=
24+
github.com/aws/aws-sdk-go-v2/credentials v1.19.17 h1:gP2nkGsS+KMvF/jfFz2Vv2qiiOqWKyPACSzPsqHgoW8=
25+
github.com/aws/aws-sdk-go-v2/credentials v1.19.17/go.mod h1:Bsew3S/moG5iT77giPj1q8wb/s0RE5/QfH+ASjYtuQc=
26+
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 h1:UuSfcORqNSz/ey3VPRS8TcVH2Ikf0/sC+Hdj400QI6U=
27+
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23/go.mod h1:+G/OSGiOFnSOkYloKj/9M35s74LgVAdJBSD5lsFfqKg=
28+
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 h1:GpT/TrnBYuE5gan2cZbTtvP+JlHsutdmlV2YfEyNde0=
29+
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23/go.mod h1:xYWD6BS9ywC5bS3sz9Xh04whO/hzK2plt2Zkyrp4JuA=
30+
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 h1:bpd8vxhlQi2r1hiueOw02f/duEPTMK59Q4QMAoTTtTo=
31+
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23/go.mod h1:15DfR2nw+CRHIk0tqNyifu3G1YdAOy68RftkhMDDwYk=
32+
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 h1:OQqn11BtaYv1WLUowvcA30MpzIu8Ti4pcLPIIyoKZrA=
33+
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24/go.mod h1:X5ZJyfwVrWA96GzPmUCWFQaEARPR7gCrpq2E92PJwAE=
34+
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 h1:FLudkZLt5ci0ozzgkVo8BJGwvqNaZbTWb3UcucAateA=
35+
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9/go.mod h1:w7wZ/s9qK7c8g4al+UyoF1Sp/Z45UwMGcqIzLWVQHWk=
36+
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 h1:pbrxO/kuIwgEsOPLkaHu0O+m4fNgLU8B3vxQ+72jTPw=
37+
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23/go.mod h1:/CMNUqoj46HpS3MNRDEDIwcgEnrtZlKRaHNaHxIFpNA=
38+
github.com/aws/aws-sdk-go-v2/service/kms v1.52.0 h1:QNtg+Mtj1zmepk568+UKBD5DFfqh+ESTUUqQT27JkQc=
39+
github.com/aws/aws-sdk-go-v2/service/kms v1.52.0/go.mod h1:Y0+uxvxz6ib4KktRdK0V4X45Vcs/JyYoz8H71pO8xeI=
40+
github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 h1:TdJ+HdzOBhU8+iVAOGUTU63VXopcumCOF1paFulHWZc=
41+
github.com/aws/aws-sdk-go-v2/service/signin v1.0.11/go.mod h1:R82ZRExE/nheo0N+T8zHPcLRTcH8MGsnR3BiVGX0TwI=
42+
github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 h1:7byT8HUWrgoRp6sXjxtZwgOKfhss5fW6SkLBtqzgRoE=
43+
github.com/aws/aws-sdk-go-v2/service/sso v1.30.17/go.mod h1:xNWknVi4Ezm1vg1QsB/5EWpAJURq22uqd38U8qKvOJc=
44+
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.0 h1:nDARhv/oF55bcxF7rCI/4PDxOKnVXVWwDuDwCs2I2SQ=
45+
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.0/go.mod h1:4vIRDq+CJB2xFAXZ+YgGUTiEft7oAQlhIs71xcSeuVg=
46+
github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 h1:F/M5Y9I3nwr2IEpshZgh1GeHpOItExNM9L1euNuh/fk=
47+
github.com/aws/aws-sdk-go-v2/service/sts v1.42.1/go.mod h1:mTNxImtovCOEEuD65mKW7DCsL+2gjEH+RPEAexAzAio=
48+
github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI=
49+
github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
2050
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
2151
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
2252
github.com/badoux/checkmail v0.0.0-20170203135005-d0a759655d62 h1:vMqcPzLT1/mbYew0gM6EJy4/sCNy9lY9rmlFO+pPwhY=

hack/kms-rsa-to-jwk.js

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
#!/usr/bin/env node
2+
3+
import { execFileSync } from "node:child_process";
4+
import { webcrypto } from "node:crypto";
5+
6+
const keyId = process.argv[2];
7+
const compact = process.argv[3] === '--compact';
8+
9+
if (!keyId) {
10+
console.error("Usage: kms-rsa-to-jwk.js <key-arn> [--compact]");
11+
process.exit(1);
12+
}
13+
14+
// arn:partition:kms:region:account:key/uuid
15+
const arnParts = keyId.split(":");
16+
if (arnParts.length < 6 || arnParts[2] !== "kms") {
17+
throw new Error(`Invalid KMS ARN: ${keyId}`);
18+
}
19+
20+
const region = arnParts[3];
21+
22+
const publicKeyB64 = execFileSync(
23+
"aws",
24+
[
25+
"kms",
26+
"get-public-key",
27+
"--key-id",
28+
keyId,
29+
"--query",
30+
"PublicKey",
31+
"--output",
32+
"text",
33+
"--region",
34+
region,
35+
],
36+
{ encoding: "utf8" },
37+
).trim();
38+
39+
const spki = Buffer.from(publicKeyB64, "base64");
40+
41+
const key = await webcrypto.subtle.importKey(
42+
"spki",
43+
spki,
44+
{
45+
name: "RSASSA-PKCS1-v1_5",
46+
hash: "SHA-256",
47+
},
48+
true,
49+
["verify"],
50+
);
51+
52+
const jwk = await webcrypto.subtle.exportKey("jwk", key);
53+
54+
console.log(
55+
JSON.stringify(
56+
{
57+
...jwk,
58+
ext: undefined,
59+
use: "sig",
60+
key_ops: ['sign', 'verify'],
61+
'aws:kms:arn': keyId,
62+
63+
//kty: jwk.kty,
64+
//use: "sig",
65+
//alg: "RS256",
66+
//kid: keyId,
67+
//n: jwk.n,
68+
//e: jwk.e,
69+
},
70+
null,
71+
compact ? 0 : 2,
72+
),
73+
);

internal/api/auth.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ func (a *API) parseJWTClaims(bearer string, r *http.Request) (context.Context, e
8282
token, err := p.ParseWithClaims(bearer, &AccessTokenClaims{}, func(token *jwt.Token) (interface{}, error) {
8383
if kid, ok := token.Header["kid"]; ok {
8484
if kidStr, ok := kid.(string); ok {
85-
key, err := conf.FindPublicKeyByKid(kidStr, &config.JWT)
85+
key, err := conf.FindPublicKeyByKid(ctx, kidStr, &config.JWT)
8686
if err != nil {
8787
return nil, err
8888
}

internal/api/auth_test.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package api
22

33
import (
4+
"context"
45
"encoding/json"
56
"net/http"
67
"net/http/httptest"
@@ -167,7 +168,7 @@ func (ts *AuthTestSuite) TestParseJWTClaims() {
167168
jwk, err := conf.GetSigningJwk(&ts.Config.JWT)
168169
require.NoError(ts.T(), err)
169170
signingMethod := conf.GetSigningAlg(jwk)
170-
signingKey, err := conf.GetSigningKey(jwk)
171+
signingKey, err := ts.Config.JWT.SigningKey(context.Background())
171172
require.NoError(ts.T(), err)
172173

173174
userJwtToken := jwt.NewWithClaims(signingMethod, userClaims)

internal/api/e2e_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -980,7 +980,7 @@ func TestE2EHooks(t *testing.T) {
980980
) (any, error) {
981981
if kid, ok := token.Header["kid"]; ok {
982982
if kidStr, ok := kid.(string); ok {
983-
return conf.FindPublicKeyByKid(kidStr, &globalCfg.JWT)
983+
return conf.FindPublicKeyByKid(context.Background(), kidStr, &globalCfg.JWT)
984984
}
985985
}
986986
if alg, ok := token.Header["alg"]; ok {
@@ -1055,7 +1055,7 @@ func TestE2EHooks(t *testing.T) {
10551055
func(token *jwt.Token) (any, error) {
10561056
if kid, ok := token.Header["kid"]; ok {
10571057
if kidStr, ok := kid.(string); ok {
1058-
return conf.FindPublicKeyByKid(kidStr, &globalCfg.JWT)
1058+
return conf.FindPublicKeyByKid(context.Background(), kidStr, &globalCfg.JWT)
10591059
}
10601060
}
10611061
if alg, ok := token.Header["alg"]; ok {

internal/api/oauthserver/handlers.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -435,7 +435,7 @@ func (s *Server) handleAuthorizationCodeGrant(ctx context.Context, w http.Respon
435435
nonce = *authorization.Nonce
436436
}
437437

438-
idToken, err := tokenService.GenerateIDToken(tokens.GenerateIDTokenParams{
438+
idToken, err := tokenService.GenerateIDToken(ctx, tokens.GenerateIDTokenParams{
439439
User: user,
440440
ClientID: client.ID,
441441
Nonce: nonce,

0 commit comments

Comments
 (0)