mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-06 00:02:15 +00:00
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:
co-authored by
Devin AI
parent
7e8bad00e6
commit
0ea856537e
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Generated
+2
-2
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user