Merge branch 'next' into next-main

This commit is contained in:
Tim Möhlmann 2023-10-12 15:08:55 +03:00
commit d9487ef77d
118 changed files with 6091 additions and 981 deletions

View file

@ -1,5 +1,9 @@
package oidc
import (
"golang.org/x/exp/slog"
)
const (
// ScopeOpenID defines the scope `openid`
// OpenID Connect requests MUST contain the `openid` scope value
@ -77,7 +81,7 @@ type AuthRequest struct {
UILocales Locales `json:"ui_locales" schema:"ui_locales"`
IDTokenHint string `json:"id_token_hint" schema:"id_token_hint"`
LoginHint string `json:"login_hint" schema:"login_hint"`
ACRValues []string `json:"acr_values" schema:"acr_values"`
ACRValues SpaceDelimitedArray `json:"acr_values" schema:"acr_values"`
CodeChallenge string `json:"code_challenge" schema:"code_challenge"`
CodeChallengeMethod CodeChallengeMethod `json:"code_challenge_method" schema:"code_challenge_method"`
@ -86,6 +90,15 @@ type AuthRequest struct {
RequestParam string `schema:"request"`
}
func (a *AuthRequest) LogValue() slog.Value {
return slog.GroupValue(
slog.Any("scopes", a.Scopes),
slog.String("response_type", string(a.ResponseType)),
slog.String("client_id", a.ClientID),
slog.String("redirect_uri", a.RedirectURI),
)
}
// GetRedirectURI returns the redirect_uri value for the ErrAuthRequest interface
func (a *AuthRequest) GetRedirectURI() string {
return a.RedirectURI

View file

@ -0,0 +1,27 @@
//go:build go1.20
package oidc
import (
"testing"
"github.com/stretchr/testify/assert"
"golang.org/x/exp/slog"
)
func TestAuthRequest_LogValue(t *testing.T) {
a := &AuthRequest{
Scopes: SpaceDelimitedArray{"a", "b"},
ResponseType: "respType",
ClientID: "123",
RedirectURI: "http://example.com/callback",
}
want := slog.GroupValue(
slog.Any("scopes", SpaceDelimitedArray{"a", "b"}),
slog.String("response_type", "respType"),
slog.String("client_id", "123"),
slog.String("redirect_uri", "http://example.com/callback"),
)
got := a.LogValue()
assert.Equal(t, want, got)
}

View file

@ -3,7 +3,7 @@ package oidc
import (
"crypto/sha256"
"github.com/zitadel/oidc/v2/pkg/crypto"
"github.com/zitadel/oidc/v3/pkg/crypto"
)
const (

View file

@ -3,6 +3,8 @@ package oidc
import (
"errors"
"fmt"
"golang.org/x/exp/slog"
)
type errorType string
@ -171,3 +173,34 @@ func DefaultToServerError(err error, description string) *Error {
}
return oauth
}
func (e *Error) LogLevel() slog.Level {
level := slog.LevelWarn
if e.ErrorType == ServerError {
level = slog.LevelError
}
if e.ErrorType == AuthorizationPending {
level = slog.LevelInfo
}
return level
}
func (e *Error) LogValue() slog.Value {
attrs := make([]slog.Attr, 0, 5)
if e.Parent != nil {
attrs = append(attrs, slog.Any("parent", e.Parent))
}
if e.Description != "" {
attrs = append(attrs, slog.String("description", e.Description))
}
if e.ErrorType != "" {
attrs = append(attrs, slog.String("type", string(e.ErrorType)))
}
if e.State != "" {
attrs = append(attrs, slog.String("state", e.State))
}
if e.redirectDisabled {
attrs = append(attrs, slog.Bool("redirect_disabled", e.redirectDisabled))
}
return slog.GroupValue(attrs...)
}

View file

@ -0,0 +1,83 @@
//go:build go1.20
package oidc
import (
"io"
"testing"
"github.com/stretchr/testify/assert"
"golang.org/x/exp/slog"
)
func TestError_LogValue(t *testing.T) {
type fields struct {
Parent error
ErrorType errorType
Description string
State string
redirectDisabled bool
}
tests := []struct {
name string
fields fields
want slog.Value
}{
{
name: "parent",
fields: fields{
Parent: io.EOF,
},
want: slog.GroupValue(slog.Any("parent", io.EOF)),
},
{
name: "description",
fields: fields{
Description: "oops",
},
want: slog.GroupValue(slog.String("description", "oops")),
},
{
name: "errorType",
fields: fields{
ErrorType: ExpiredToken,
},
want: slog.GroupValue(slog.String("type", string(ExpiredToken))),
},
{
name: "state",
fields: fields{
State: "123",
},
want: slog.GroupValue(slog.String("state", "123")),
},
{
name: "all fields",
fields: fields{
Parent: io.EOF,
Description: "oops",
ErrorType: ExpiredToken,
State: "123",
},
want: slog.GroupValue(
slog.Any("parent", io.EOF),
slog.String("description", "oops"),
slog.String("type", string(ExpiredToken)),
slog.String("state", "123"),
),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
e := &Error{
Parent: tt.fields.Parent,
ErrorType: tt.fields.ErrorType,
Description: tt.fields.Description,
State: tt.fields.State,
redirectDisabled: tt.fields.redirectDisabled,
}
got := e.LogValue()
assert.Equal(t, tt.want, got)
})
}
}

81
pkg/oidc/error_test.go Normal file
View file

@ -0,0 +1,81 @@
package oidc
import (
"io"
"testing"
"github.com/stretchr/testify/assert"
"golang.org/x/exp/slog"
)
func TestDefaultToServerError(t *testing.T) {
type args struct {
err error
description string
}
tests := []struct {
name string
args args
want *Error
}{
{
name: "default",
args: args{
err: io.ErrClosedPipe,
description: "oops",
},
want: &Error{
ErrorType: ServerError,
Description: "oops",
Parent: io.ErrClosedPipe,
},
},
{
name: "our Error",
args: args{
err: ErrAccessDenied(),
description: "oops",
},
want: &Error{
ErrorType: AccessDenied,
Description: "The authorization request was denied.",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := DefaultToServerError(tt.args.err, tt.args.description)
assert.ErrorIs(t, got, tt.want)
})
}
}
func TestError_LogLevel(t *testing.T) {
tests := []struct {
name string
err *Error
want slog.Level
}{
{
name: "server error",
err: ErrServerError(),
want: slog.LevelError,
},
{
name: "authorization pending",
err: ErrAuthorizationPending(),
want: slog.LevelInfo,
},
{
name: "some other error",
err: ErrAccessDenied(),
want: slog.LevelWarn,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tt.err.LogLevel()
assert.Equal(t, tt.want, got)
})
}
}

View file

@ -7,7 +7,7 @@ import (
"crypto/rsa"
"errors"
"gopkg.in/square/go-jose.v2"
jose "github.com/go-jose/go-jose/v3"
)
const (

View file

@ -7,7 +7,7 @@ import (
"reflect"
"testing"
"gopkg.in/square/go-jose.v2"
jose "github.com/go-jose/go-jose/v3"
)
func TestFindKey(t *testing.T) {

View file

@ -5,11 +5,11 @@ import (
"os"
"time"
jose "github.com/go-jose/go-jose/v3"
"golang.org/x/oauth2"
"gopkg.in/square/go-jose.v2"
"github.com/muhlemmer/gu"
"github.com/zitadel/oidc/v2/pkg/crypto"
"github.com/zitadel/oidc/v3/pkg/crypto"
)
const (

View file

@ -5,7 +5,7 @@ import (
"fmt"
"time"
"gopkg.in/square/go-jose.v2"
jose "github.com/go-jose/go-jose/v3"
)
const (

View file

@ -4,9 +4,9 @@ import (
"testing"
"time"
jose "github.com/go-jose/go-jose/v3"
"github.com/stretchr/testify/assert"
"golang.org/x/text/language"
"gopkg.in/square/go-jose.v2"
)
var (

View file

@ -8,10 +8,10 @@ import (
"strings"
"time"
"github.com/gorilla/schema"
jose "github.com/go-jose/go-jose/v3"
"github.com/muhlemmer/gu"
"github.com/zitadel/schema"
"golang.org/x/text/language"
"gopkg.in/square/go-jose.v2"
)
type Audience []string
@ -151,7 +151,7 @@ type ResponseType string
type ResponseMode string
func (s SpaceDelimitedArray) Encode() string {
func (s SpaceDelimitedArray) String() string {
return strings.Join(s, " ")
}
@ -161,11 +161,11 @@ func (s *SpaceDelimitedArray) UnmarshalText(text []byte) error {
}
func (s SpaceDelimitedArray) MarshalText() ([]byte, error) {
return []byte(s.Encode()), nil
return []byte(s.String()), nil
}
func (s SpaceDelimitedArray) MarshalJSON() ([]byte, error) {
return json.Marshal((s).Encode())
return json.Marshal((s).String())
}
func (s *SpaceDelimitedArray) UnmarshalJSON(data []byte) error {
@ -210,7 +210,7 @@ func (s SpaceDelimitedArray) Value() (driver.Value, error) {
func NewEncoder() *schema.Encoder {
e := schema.NewEncoder()
e.RegisterEncoder(SpaceDelimitedArray{}, func(value reflect.Value) string {
return value.Interface().(SpaceDelimitedArray).Encode()
return value.Interface().(SpaceDelimitedArray).String()
})
return e
}

View file

@ -9,9 +9,9 @@ import (
"testing"
"time"
"github.com/gorilla/schema"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/zitadel/schema"
"golang.org/x/text/language"
)

View file

@ -29,6 +29,11 @@ func (u *UserInfo) GetAddress() *UserInfoAddress {
return u.Address
}
// GetSubject implements [rp.SubjectGetter]
func (u *UserInfo) GetSubject() string {
return u.Subject
}
type uiAlias UserInfo
func (u *UserInfo) MarshalJSON() ([]byte, error) {

View file

@ -10,9 +10,9 @@ import (
"strings"
"time"
"gopkg.in/square/go-jose.v2"
jose "github.com/go-jose/go-jose/v3"
str "github.com/zitadel/oidc/v2/pkg/strings"
str "github.com/zitadel/oidc/v3/pkg/strings"
)
type Claims interface {
@ -61,10 +61,19 @@ var (
ErrAtHash = errors.New("at_hash does not correspond to access token")
)
type Verifier interface {
Issuer() string
MaxAgeIAT() time.Duration
Offset() time.Duration
// Verifier caries configuration for the various token verification
// functions. Use package specific constructor functions to know
// which values need to be set.
type Verifier struct {
Issuer string
MaxAgeIAT time.Duration
Offset time.Duration
ClientID string
SupportedSignAlgs []string
MaxAge time.Duration
ACR ACRVerifier
KeySet KeySet
Nonce func(ctx context.Context) string
}
// ACRVerifier specifies the function to be used by the `DefaultVerifier` for validating the acr claim
@ -121,6 +130,11 @@ func CheckAudience(claims Claims, clientID string) error {
return nil
}
// CheckAuthorizedParty checks azp (authorized party) claim requirements.
//
// If the ID Token contains multiple audiences, the Client SHOULD verify that an azp Claim is present.
// If an azp Claim is present, the Client SHOULD verify that its client_id is the Claim Value.
// https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation
func CheckAuthorizedParty(claims Claims, clientID string) error {
if len(claims.GetAudience()) > 1 {
if claims.GetAuthorizedParty() == "" {
@ -167,26 +181,26 @@ func CheckSignature(ctx context.Context, token string, payload []byte, claims Cl
}
func CheckExpiration(claims Claims, offset time.Duration) error {
expiration := claims.GetExpiration().Round(time.Second)
if !time.Now().UTC().Add(offset).Before(expiration) {
expiration := claims.GetExpiration()
if !time.Now().Add(offset).Before(expiration) {
return ErrExpired
}
return nil
}
func CheckIssuedAt(claims Claims, maxAgeIAT, offset time.Duration) error {
issuedAt := claims.GetIssuedAt().Round(time.Second)
issuedAt := claims.GetIssuedAt()
if issuedAt.IsZero() {
return ErrIatMissing
}
nowWithOffset := time.Now().UTC().Add(offset).Round(time.Second)
nowWithOffset := time.Now().Add(offset).Round(time.Second)
if issuedAt.After(nowWithOffset) {
return fmt.Errorf("%w: (iat: %v, now with offset: %v)", ErrIatInFuture, issuedAt, nowWithOffset)
}
if maxAgeIAT == 0 {
return nil
}
maxAge := time.Now().UTC().Add(-maxAgeIAT).Round(time.Second)
maxAge := time.Now().Add(-maxAgeIAT).Round(time.Second)
if issuedAt.Before(maxAge) {
return fmt.Errorf("%w: must not be older than %v, but was %v (%v to old)", ErrIatToOld, maxAge, issuedAt, maxAge.Sub(issuedAt))
}
@ -216,8 +230,8 @@ func CheckAuthTime(claims Claims, maxAge time.Duration) error {
if claims.GetAuthTime().IsZero() {
return ErrAuthTimeNotPresent
}
authTime := claims.GetAuthTime().Round(time.Second)
maxAuthTime := time.Now().UTC().Add(-maxAge).Round(time.Second)
authTime := claims.GetAuthTime()
maxAuthTime := time.Now().Add(-maxAge).Round(time.Second)
if authTime.Before(maxAuthTime) {
return fmt.Errorf("%w: must not be older than %v, but was %v (%v to old)", ErrAuthTimeToOld, maxAge, authTime, maxAuthTime.Sub(authTime))
}

View file

@ -0,0 +1,128 @@
package oidc_test
import (
"context"
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
tu "github.com/zitadel/oidc/v3/internal/testutil"
"github.com/zitadel/oidc/v3/pkg/oidc"
)
func TestParseToken(t *testing.T) {
token, wantClaims := tu.ValidIDToken()
wantClaims.SignatureAlg = "" // unset, because is not part of the JSON payload
wantPayload, err := json.Marshal(wantClaims)
require.NoError(t, err)
tests := []struct {
name string
tokenString string
wantErr bool
}{
{
name: "split error",
tokenString: "nope",
wantErr: true,
},
{
name: "base64 error",
tokenString: "foo.~.bar",
wantErr: true,
},
{
name: "success",
tokenString: token,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotClaims := new(oidc.IDTokenClaims)
gotPayload, err := oidc.ParseToken(tt.tokenString, gotClaims)
if tt.wantErr {
assert.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, wantClaims, gotClaims)
assert.JSONEq(t, string(wantPayload), string(gotPayload))
})
}
}
func TestCheckSignature(t *testing.T) {
errCtx, cancel := context.WithCancel(context.Background())
cancel()
token, _ := tu.ValidIDToken()
payload, err := oidc.ParseToken(token, &oidc.IDTokenClaims{})
require.NoError(t, err)
type args struct {
ctx context.Context
token string
payload []byte
supportedSigAlgs []string
}
tests := []struct {
name string
args args
wantErr error
}{
{
name: "parse error",
args: args{
ctx: context.Background(),
token: "~",
payload: payload,
},
wantErr: oidc.ErrParse,
},
{
name: "default sigAlg",
args: args{
ctx: context.Background(),
token: token,
payload: payload,
},
},
{
name: "unsupported sigAlg",
args: args{
ctx: context.Background(),
token: token,
payload: payload,
supportedSigAlgs: []string{"foo", "bar"},
},
wantErr: oidc.ErrSignatureUnsupportedAlg,
},
{
name: "verify error",
args: args{
ctx: errCtx,
token: token,
payload: payload,
},
wantErr: oidc.ErrSignatureInvalid,
},
{
name: "inequal payloads",
args: args{
ctx: context.Background(),
token: token,
payload: []byte{0, 1, 2},
},
wantErr: oidc.ErrSignatureInvalidPayload,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
claims := new(oidc.TokenClaims)
err := oidc.CheckSignature(tt.args.ctx, tt.args.token, tt.args.payload, claims, tt.args.supportedSigAlgs, tu.KeySet{})
assert.ErrorIs(t, err, tt.wantErr)
})
}
}

374
pkg/oidc/verifier_test.go Normal file
View file

@ -0,0 +1,374 @@
package oidc
import (
"errors"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestDecryptToken(t *testing.T) {
const tokenString = "ABC"
got, err := DecryptToken(tokenString)
require.NoError(t, err)
assert.Equal(t, tokenString, got)
}
func TestDefaultACRVerifier(t *testing.T) {
acrVerfier := DefaultACRVerifier([]string{"foo", "bar"})
tests := []struct {
name string
acr string
wantErr string
}{
{
name: "ok",
acr: "bar",
},
{
name: "error",
acr: "hello",
wantErr: "expected one of: [foo bar], got: \"hello\"",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := acrVerfier(tt.acr)
if tt.wantErr != "" {
assert.EqualError(t, err, tt.wantErr)
return
}
require.NoError(t, err)
})
}
}
func TestCheckSubject(t *testing.T) {
tests := []struct {
name string
claims Claims
wantErr error
}{
{
name: "missing",
claims: &TokenClaims{},
wantErr: ErrSubjectMissing,
},
{
name: "ok",
claims: &TokenClaims{
Subject: "foo",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := CheckSubject(tt.claims)
assert.ErrorIs(t, err, tt.wantErr)
})
}
}
func TestCheckIssuer(t *testing.T) {
const issuer = "foo.bar"
tests := []struct {
name string
claims Claims
wantErr error
}{
{
name: "missing",
claims: &TokenClaims{},
wantErr: ErrIssuerInvalid,
},
{
name: "wrong",
claims: &TokenClaims{
Issuer: "wrong",
},
wantErr: ErrIssuerInvalid,
},
{
name: "ok",
claims: &TokenClaims{
Issuer: issuer,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := CheckIssuer(tt.claims, issuer)
assert.ErrorIs(t, err, tt.wantErr)
})
}
}
func TestCheckAudience(t *testing.T) {
const clientID = "foo.bar"
tests := []struct {
name string
claims Claims
wantErr error
}{
{
name: "missing",
claims: &TokenClaims{},
wantErr: ErrAudience,
},
{
name: "wrong",
claims: &TokenClaims{
Audience: []string{"wrong"},
},
wantErr: ErrAudience,
},
{
name: "ok",
claims: &TokenClaims{
Audience: []string{clientID},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := CheckAudience(tt.claims, clientID)
assert.ErrorIs(t, err, tt.wantErr)
})
}
}
func TestCheckAuthorizedParty(t *testing.T) {
const clientID = "foo.bar"
tests := []struct {
name string
claims Claims
wantErr error
}{
{
name: "single audience, no azp",
claims: &TokenClaims{
Audience: []string{clientID},
},
},
{
name: "multiple audience, no azp",
claims: &TokenClaims{
Audience: []string{clientID, "other"},
},
wantErr: ErrAzpMissing,
},
{
name: "single audience, with azp",
claims: &TokenClaims{
Audience: []string{clientID},
AuthorizedParty: clientID,
},
},
{
name: "multiple audience, with azp",
claims: &TokenClaims{
Audience: []string{clientID, "other"},
AuthorizedParty: clientID,
},
},
{
name: "wrong azp",
claims: &TokenClaims{
AuthorizedParty: "wrong",
},
wantErr: ErrAzpInvalid,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := CheckAuthorizedParty(tt.claims, clientID)
assert.ErrorIs(t, err, tt.wantErr)
})
}
}
func TestCheckExpiration(t *testing.T) {
const offset = time.Minute
tests := []struct {
name string
claims Claims
wantErr error
}{
{
name: "missing",
claims: &TokenClaims{},
wantErr: ErrExpired,
},
{
name: "expired",
claims: &TokenClaims{
Expiration: FromTime(time.Now().Add(-2 * offset)),
},
wantErr: ErrExpired,
},
{
name: "valid",
claims: &TokenClaims{
Expiration: FromTime(time.Now().Add(2 * offset)),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := CheckExpiration(tt.claims, offset)
assert.ErrorIs(t, err, tt.wantErr)
})
}
}
func TestCheckIssuedAt(t *testing.T) {
const offset = time.Minute
tests := []struct {
name string
maxAgeIAT time.Duration
claims Claims
wantErr error
}{
{
name: "missing",
claims: &TokenClaims{},
wantErr: ErrIatMissing,
},
{
name: "future",
claims: &TokenClaims{
IssuedAt: FromTime(time.Now().Add(time.Hour)),
},
wantErr: ErrIatInFuture,
},
{
name: "no max",
claims: &TokenClaims{
IssuedAt: FromTime(time.Now()),
},
},
{
name: "past max",
maxAgeIAT: time.Minute,
claims: &TokenClaims{
IssuedAt: FromTime(time.Now().Add(-time.Hour)),
},
wantErr: ErrIatToOld,
},
{
name: "within max",
maxAgeIAT: time.Hour,
claims: &TokenClaims{
IssuedAt: FromTime(time.Now()),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := CheckIssuedAt(tt.claims, tt.maxAgeIAT, offset)
assert.ErrorIs(t, err, tt.wantErr)
})
}
}
func TestCheckNonce(t *testing.T) {
const nonce = "123"
tests := []struct {
name string
claims Claims
wantErr error
}{
{
name: "missing",
claims: &TokenClaims{},
wantErr: ErrNonceInvalid,
},
{
name: "wrong",
claims: &TokenClaims{
Nonce: "wrong",
},
wantErr: ErrNonceInvalid,
},
{
name: "ok",
claims: &TokenClaims{
Nonce: nonce,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := CheckNonce(tt.claims, nonce)
assert.ErrorIs(t, err, tt.wantErr)
})
}
}
func TestCheckAuthorizationContextClassReference(t *testing.T) {
tests := []struct {
name string
acr ACRVerifier
wantErr error
}{
{
name: "error",
acr: func(s string) error { return errors.New("oops") },
wantErr: ErrAcrInvalid,
},
{
name: "ok",
acr: func(s string) error { return nil },
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := CheckAuthorizationContextClassReference(&IDTokenClaims{}, tt.acr)
assert.ErrorIs(t, err, tt.wantErr)
})
}
}
func TestCheckAuthTime(t *testing.T) {
tests := []struct {
name string
claims Claims
maxAge time.Duration
wantErr error
}{
{
name: "no max age",
claims: &TokenClaims{},
},
{
name: "missing",
claims: &TokenClaims{},
maxAge: time.Minute,
wantErr: ErrAuthTimeNotPresent,
},
{
name: "expired",
maxAge: time.Minute,
claims: &TokenClaims{
AuthTime: FromTime(time.Now().Add(-time.Hour)),
},
wantErr: ErrAuthTimeToOld,
},
{
name: "ok",
maxAge: time.Minute,
claims: &TokenClaims{
AuthTime: NowTime(),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := CheckAuthTime(tt.claims, tt.maxAge)
assert.ErrorIs(t, err, tt.wantErr)
})
}
}