fix(security): drop jwt-go via first-party Apple auth, bump yoke-derive, grant checks:write to rust audit

Refs WAR-10

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Matthew Meszaros
2026-10-04 18:36:28 +00:00
co-authored by Devin AI
parent 7e8bad00e6
commit 0ea856537e
9 changed files with 354 additions and 29 deletions
+5
View File
@@ -86,6 +86,11 @@ jobs:
rust:
name: Rust Dependencies
runs-on: ubuntu-latest
# audit-check reports its result as a check run, which the workflow-wide
# read-only token cannot create.
permissions:
contents: read
checks: write
steps:
- uses: actions/checkout@v4
- uses: rustsec/audit-check@v2
+3 -3
View File
@@ -19,7 +19,6 @@ import (
"github.com/aws/aws-sdk-go-v2/aws"
awsconf "github.com/aws/aws-sdk-go-v2/config"
"github.com/google/uuid"
"github.com/meszmate/apple-go"
"github.com/redis/go-redis/v9"
"github.com/warmbly/warmbly/internal/api"
"github.com/warmbly/warmbly/internal/api/handler"
@@ -142,6 +141,7 @@ import (
"github.com/warmbly/warmbly/internal/notify"
"github.com/warmbly/warmbly/internal/observability"
productanalytics "github.com/warmbly/warmbly/internal/observability/analytics"
"github.com/warmbly/warmbly/internal/pkg/appleauth"
"github.com/warmbly/warmbly/internal/pkg/captcha"
"github.com/warmbly/warmbly/internal/pkg/domainproof"
"github.com/warmbly/warmbly/internal/pkg/emailverify"
@@ -572,9 +572,9 @@ func main() {
// Apple Sign in is optional. Skip it entirely when unconfigured (a
// self-host without Apple creds); only warn — never fatal — when creds
// are present but init fails, so Apple simply stays unavailable.
var appleAuthClient apple.AppleAuth
var appleAuthClient socialauth.AppleCodeExchanger
if authCfg.AppleAppID != "" || authCfg.AppleKeySecret != "" {
appleAuthInstance, appleErr := apple.NewB64(
appleAuthInstance, appleErr := appleauth.NewFromBase64(
authCfg.AppleAppID,
authCfg.AppleTeamID,
authCfg.AppleKeyID,
-4
View File
@@ -40,7 +40,6 @@ require (
github.com/hamba/avro/v2 v2.31.0
github.com/invopop/jsonschema v0.13.0
github.com/jackc/pgx/v5 v5.11.0
github.com/meszmate/apple-go v0.0.0-20250828163208-7fea48c91b32
github.com/microcosm-cc/bluemonday v1.0.27
github.com/mileusna/useragent v1.3.5
github.com/nats-io/jwt/v2 v2.8.1
@@ -136,7 +135,6 @@ require (
github.com/daixiang0/gci v0.13.5 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/denis-tingaikin/go-header v0.5.0 // indirect
github.com/dgrijalva/jwt-go v3.2.0+incompatible // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/ettle/strcase v0.2.0 // indirect
github.com/fatih/color v1.18.0 // indirect
@@ -287,8 +285,6 @@ require (
github.com/subosito/gotenv v1.4.1 // indirect
github.com/tdakkota/asciicheck v0.4.1 // indirect
github.com/tetafro/godot v1.5.0 // indirect
github.com/tideland/golib v4.24.2+incompatible // indirect
github.com/tideland/gorest v2.15.5+incompatible // indirect
github.com/tidwall/gjson v1.14.4 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
-8
View File
@@ -237,8 +237,6 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/denis-tingaikin/go-header v0.5.0 h1:SRdnP5ZKvcO9KKRP1KJrhFR3RrlGuD+42t4429eC9k8=
github.com/denis-tingaikin/go-header v0.5.0/go.mod h1:mMenU5bWrok6Wl2UsZjy+1okegmwQ3UgWl4V1D8gjlY=
github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM=
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4=
@@ -620,8 +618,6 @@ github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6T
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk=
github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y=
github.com/meszmate/apple-go v0.0.0-20250828163208-7fea48c91b32 h1:t39Q4yEIyvvCiUquqFvKwmwJUT7lOYIJhLfrBRalQSM=
github.com/meszmate/apple-go v0.0.0-20250828163208-7fea48c91b32/go.mod h1:CbFm63AASrsMV+GeO2/UhxSJJ0oUy52ZGpzynk1OSUI=
github.com/mgechev/revive v1.7.0 h1:JyeQ4yO5K8aZhIKf5rec56u0376h8AlKNQEmjfkjKlY=
github.com/mgechev/revive v1.7.0/go.mod h1:qZnwcNhoguE58dfi96IJeSTPeZQejNeoMQLUZGi4SW4=
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4=
@@ -879,10 +875,6 @@ github.com/tetafro/godot v1.5.0 h1:aNwfVI4I3+gdxjMgYPus9eHmoBeJIbnajOyqZYStzuw=
github.com/tetafro/godot v1.5.0/go.mod h1:2oVxTBSftRTh4+MVfUaUXR6bn2GDXCaMcOG4Dk3rfio=
github.com/theupdateframework/notary v0.7.0 h1:QyagRZ7wlSpjT5N2qQAh/pN+DVqgekv4DzbAiAiEL3c=
github.com/theupdateframework/notary v0.7.0/go.mod h1:c9DRxcmhHmVLDay4/2fUYdISnHqbFDGRSlXPO0AhYWw=
github.com/tideland/golib v4.24.2+incompatible h1:QYMkA3Sr1G8UWJTDsptrLOUwB6N89ETlVBnK5lZXKAw=
github.com/tideland/golib v4.24.2+incompatible/go.mod h1:HPHOmtCdCHUQiGAVZnlOH5eNTAEmM7R9oCFXdgvkB+Y=
github.com/tideland/gorest v2.15.5+incompatible h1:R19qOZQaCzT0x7ZExRd3avyG39jNLFeq2/HYetctYYo=
github.com/tideland/gorest v2.15.5+incompatible/go.mod h1:iCPpLOEr3tuQa96whkwiNTyYK4u6PTpWRxf5wGAvYLQ=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM=
github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
+14 -8
View File
@@ -13,7 +13,7 @@ import (
"fmt"
"strings"
apple "github.com/meszmate/apple-go"
"github.com/warmbly/warmbly/internal/pkg/appleauth"
"github.com/warmbly/warmbly/internal/pkg/idtoken"
"golang.org/x/oauth2"
googleendpoint "golang.org/x/oauth2/google"
@@ -100,15 +100,21 @@ func (g *Google) Exchange(ctx context.Context, code, verifier, expectedNonce str
// only sends the email claim when the email scope is requested, and any scope
// forces response_mode=form_post, so its callback arrives as a cross-site POST.
type Apple struct {
client apple.AppleAuth
client AppleCodeExchanger
servicesID string
redirectURL string
verifier *idtoken.Verifier
}
// AppleCodeExchanger trades an authorization code at Apple's token endpoint;
// *appleauth.Client is the production implementation.
type AppleCodeExchanger interface {
ExchangeCode(ctx context.Context, code, redirectURI string) (*appleauth.TokenResponse, error)
}
// NewApple builds the flow from the same credentials the native path uses. The
// client id is the Services ID (the web identifier), not the app's bundle ID.
func NewApple(client apple.AppleAuth, servicesID, redirectURL string) (*Apple, error) {
func NewApple(client AppleCodeExchanger, servicesID, redirectURL string) (*Apple, error) {
if client == nil {
return nil, errors.New("socialauth: apple client is not configured")
}
@@ -139,14 +145,14 @@ func (a *Apple) RedirectURL() string { return a.redirectURL }
// the web flow, so the verifier is ignored; one-time state and the nonce inside
// the ID token are what bind the response to this attempt.
func (a *Apple) AuthCodeURL(state, nonce, _ string) string {
return apple.AuthorizeURL(apple.AuthorizeURLConfig{
return appleauth.AuthorizeURL(appleauth.AuthorizeURLConfig{
ClientID: a.servicesID,
RedirectURI: a.redirectURL,
State: state,
Nonce: nonce,
Scope: []string{"name", "email"},
ResponseType: apple.ResponseTypeCode,
ResponseMode: apple.ResponseModeFormPost,
ResponseType: "code",
ResponseMode: "form_post",
})
}
@@ -155,9 +161,9 @@ func (a *Apple) AuthCodeURL(state, nonce, _ string) string {
// Apple's keys, because the audience check is what rejects one issued for a
// different client.
func (a *Apple) Exchange(ctx context.Context, code, _, expectedNonce string) (*idtoken.Claims, error) {
resp, err := a.client.ValidateCodeWithRedirectURI(code, a.redirectURL)
resp, err := a.client.ExchangeCode(ctx, code, a.redirectURL)
if err != nil {
if errors.Is(err, apple.ErrorResponseInvalidGrant) {
if errors.Is(err, appleauth.ErrInvalidGrant) {
return nil, fmt.Errorf("socialauth: apple rejected the authorization code: %w", err)
}
return nil, fmt.Errorf("socialauth: apple code exchange: %w", err)
+6 -4
View File
@@ -1,13 +1,15 @@
package socialauth
import apple "github.com/meszmate/apple-go"
import (
"context"
"github.com/warmbly/warmbly/internal/pkg/appleauth"
)
// stubAppleClient stands in for the Apple token endpoint. Only construction and
// URL building are exercised here; the exchange needs Apple's live keys.
type stubAppleClient struct{}
func (stubAppleClient) ValidateCode(string) (*apple.TokenResponse, error) { return nil, nil }
func (stubAppleClient) ValidateCodeWithRedirectURI(string, string) (*apple.TokenResponse, error) {
func (stubAppleClient) ExchangeCode(context.Context, string, string) (*appleauth.TokenResponse, error) {
return nil, nil
}
func (stubAppleClient) ValidateRefreshToken(string) (*apple.TokenResponse, error) { return nil, nil }
+199
View File
@@ -0,0 +1,199 @@
// Package appleauth is the server side of Sign in with Apple's web flow: it
// builds the authorization URL and trades the returned code at Apple's token
// endpoint, authenticating with a client secret signed by the team's .p8 key.
// The ID token that comes back is verified separately (internal/pkg/idtoken).
package appleauth
import (
"context"
"crypto/ecdsa"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
)
const (
authorizeEndpoint = "https://appleid.apple.com/auth/authorize"
tokenEndpoint = "https://appleid.apple.com/auth/token"
appleAudience = "https://appleid.apple.com"
// A fresh secret is signed per exchange, so it only has to outlive the
// request; Apple's ceiling is six months.
clientSecretTTL = 5 * time.Minute
)
// ErrInvalidGrant is Apple's invalid_grant: the code was expired, already used,
// or issued for a different client or redirect URI.
var ErrInvalidGrant = errors.New("appleauth: invalid_grant")
// TokenResponse is Apple's token endpoint response.
type TokenResponse struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
IDToken string `json:"id_token"`
RefreshToken string `json:"refresh_token"`
TokenType string `json:"token_type"`
}
// Client exchanges authorization codes for one Services ID.
type Client struct {
clientID string
teamID string
keyID string
key *ecdsa.PrivateKey
http *http.Client
tokenURL string
}
// NewFromBase64 builds a client from the base64-encoded contents of the
// AuthKey_<keyID>.p8 file, the form it takes in an env var.
func NewFromBase64(clientID, teamID, keyID, keyB64 string) (*Client, error) {
if clientID == "" || teamID == "" || keyID == "" {
return nil, errors.New("appleauth: client id, team id and key id are required")
}
pemBytes, err := base64.StdEncoding.DecodeString(strings.TrimSpace(keyB64))
if err != nil {
return nil, fmt.Errorf("appleauth: decoding key: %w", err)
}
key, err := parsePrivateKey(pemBytes)
if err != nil {
return nil, err
}
return &Client{
clientID: clientID,
teamID: teamID,
keyID: keyID,
key: key,
http: &http.Client{Timeout: 10 * time.Second},
tokenURL: tokenEndpoint,
}, nil
}
func parsePrivateKey(pemBytes []byte) (*ecdsa.PrivateKey, error) {
block, _ := pem.Decode(pemBytes)
if block == nil {
return nil, errors.New("appleauth: key is not PEM encoded")
}
parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("appleauth: parsing key: %w", err)
}
key, ok := parsed.(*ecdsa.PrivateKey)
if !ok {
return nil, errors.New("appleauth: key is not an ECDSA private key")
}
return key, nil
}
func (c *Client) clientSecret(now time.Time) (string, error) {
token := jwt.NewWithClaims(jwt.SigningMethodES256, jwt.MapClaims{
"iss": c.teamID,
"sub": c.clientID,
"aud": appleAudience,
"iat": now.Unix(),
"exp": now.Add(clientSecretTTL).Unix(),
})
token.Header["kid"] = c.keyID
return token.SignedString(c.key)
}
// ExchangeCode trades an authorization code at Apple's token endpoint. The
// redirect URI must be the one the authorization request was sent with.
func (c *Client) ExchangeCode(ctx context.Context, code, redirectURI string) (*TokenResponse, error) {
secret, err := c.clientSecret(time.Now())
if err != nil {
return nil, fmt.Errorf("appleauth: signing client secret: %w", err)
}
form := url.Values{
"client_id": {c.clientID},
"client_secret": {secret},
"code": {code},
"grant_type": {"authorization_code"},
"redirect_uri": {redirectURI},
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.tokenURL, strings.NewReader(form.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
res, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("appleauth: token request: %w", err)
}
defer func() { _ = res.Body.Close() }()
body, err := io.ReadAll(io.LimitReader(res.Body, 1<<20))
if err != nil {
return nil, fmt.Errorf("appleauth: reading token response: %w", err)
}
if res.StatusCode != http.StatusOK {
var apiErr struct {
Error string `json:"error"`
}
_ = json.Unmarshal(body, &apiErr)
if apiErr.Error == "invalid_grant" {
return nil, ErrInvalidGrant
}
if apiErr.Error != "" {
return nil, fmt.Errorf("appleauth: token endpoint returned %s (HTTP %d)", apiErr.Error, res.StatusCode)
}
return nil, fmt.Errorf("appleauth: token endpoint returned HTTP %d", res.StatusCode)
}
var tr TokenResponse
if err := json.Unmarshal(body, &tr); err != nil {
return nil, fmt.Errorf("appleauth: decoding token response: %w", err)
}
return &tr, nil
}
// AuthorizeURLConfig is the authorization request. Any scope makes Apple
// require response_mode=form_post.
type AuthorizeURLConfig struct {
ClientID string
RedirectURI string
State string
Nonce string
Scope []string
ResponseType string
ResponseMode string
}
// AuthorizeURL builds the URL the browser is sent to.
func AuthorizeURL(cfg AuthorizeURLConfig) string {
responseType := cfg.ResponseType
if responseType == "" {
responseType = "code"
}
responseMode := cfg.ResponseMode
if responseMode == "" {
responseMode = "form_post"
}
q := url.Values{}
q.Set("response_type", responseType)
q.Set("response_mode", responseMode)
q.Set("client_id", cfg.ClientID)
q.Set("redirect_uri", cfg.RedirectURI)
if cfg.State != "" {
q.Set("state", cfg.State)
}
if cfg.Nonce != "" {
q.Set("nonce", cfg.Nonce)
}
if len(cfg.Scope) > 0 {
q.Set("scope", strings.Join(cfg.Scope, " "))
}
return authorizeEndpoint + "?" + q.Encode()
}
+125
View File
@@ -0,0 +1,125 @@
package appleauth
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"errors"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"github.com/golang-jwt/jwt/v5"
)
func testClient(t *testing.T, tokenURL string) (*Client, *ecdsa.PrivateKey) {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
der, err := x509.MarshalPKCS8PrivateKey(key)
if err != nil {
t.Fatal(err)
}
p8 := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})
c, err := NewFromBase64("com.example.service", "TEAM123", "KEY123", base64.StdEncoding.EncodeToString(p8))
if err != nil {
t.Fatal(err)
}
c.tokenURL = tokenURL
return c, key
}
func TestNewFromBase64RejectsBadKeys(t *testing.T) {
if _, err := NewFromBase64("id", "team", "key", "not base64!"); err == nil {
t.Error("expected an error for invalid base64")
}
if _, err := NewFromBase64("id", "team", "key", base64.StdEncoding.EncodeToString([]byte("no pem here"))); err == nil {
t.Error("expected an error for a non-PEM key")
}
if _, err := NewFromBase64("", "team", "key", ""); err == nil {
t.Error("expected an error for a missing client id")
}
}
// The client secret is what Apple authenticates the exchange with: an ES256
// JWT from the team, about the Services ID, for Apple, naming the key.
func TestExchangeCodeSendsSignedClientSecret(t *testing.T) {
var form url.Values
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
t.Error(err)
}
form = r.PostForm
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"access_token":"at","expires_in":3600,"id_token":"idt","refresh_token":"rt","token_type":"Bearer"}`))
}))
defer srv.Close()
c, key := testClient(t, srv.URL)
resp, err := c.ExchangeCode(context.Background(), "the-code", "https://api.example.com/cb")
if err != nil {
t.Fatal(err)
}
if resp.IDToken != "idt" || resp.RefreshToken != "rt" {
t.Errorf("unexpected response %+v", resp)
}
for k, want := range map[string]string{
"client_id": "com.example.service",
"code": "the-code",
"grant_type": "authorization_code",
"redirect_uri": "https://api.example.com/cb",
} {
if got := form.Get(k); got != want {
t.Errorf("%s = %q, want %q", k, got, want)
}
}
claims := jwt.MapClaims{}
tok, err := jwt.ParseWithClaims(form.Get("client_secret"), claims, func(*jwt.Token) (any, error) {
return &key.PublicKey, nil
}, jwt.WithValidMethods([]string{"ES256"}), jwt.WithIssuer("TEAM123"), jwt.WithSubject("com.example.service"), jwt.WithAudience(appleAudience))
if err != nil {
t.Fatalf("client secret does not verify: %v", err)
}
if kid := tok.Header["kid"]; kid != "KEY123" {
t.Errorf("kid = %v", kid)
}
if aud, ok := claims["aud"].(string); !ok || aud != appleAudience {
t.Errorf("aud = %#v, want the single string %q", claims["aud"], appleAudience)
}
}
func TestExchangeCodeMapsInvalidGrant(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"error":"invalid_grant"}`))
}))
defer srv.Close()
c, _ := testClient(t, srv.URL)
if _, err := c.ExchangeCode(context.Background(), "used", "https://api.example.com/cb"); !errors.Is(err, ErrInvalidGrant) {
t.Fatalf("err = %v, want ErrInvalidGrant", err)
}
}
func TestExchangeCodeReportsOtherErrors(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"error":"invalid_client"}`))
}))
defer srv.Close()
c, _ := testClient(t, srv.URL)
_, err := c.ExchangeCode(context.Background(), "code", "https://api.example.com/cb")
if err == nil || errors.Is(err, ErrInvalidGrant) {
t.Fatalf("err = %v, want a non-invalid_grant error", err)
}
}
+2 -2
View File
@@ -4371,9 +4371,9 @@ dependencies = [
[[package]]
name = "yoke-derive"
version = "0.8.3"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78"
checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71"
dependencies = [
"proc-macro2",
"quote",