diff --git a/example/server/storage/oidc.go b/example/server/storage/oidc.go
index 83db739..f5412cf 100644
--- a/example/server/storage/oidc.go
+++ b/example/server/storage/oidc.go
@@ -37,8 +37,8 @@ type AuthRequest struct {
Nonce string
CodeChallenge *OIDCCodeChallenge
- passwordChecked bool
- authTime time.Time
+ done bool
+ authTime time.Time
}
func (a *AuthRequest) GetID() string {
@@ -51,7 +51,7 @@ func (a *AuthRequest) GetACR() string {
func (a *AuthRequest) GetAMR() []string {
// this example only uses password for authentication
- if a.passwordChecked {
+ if a.done {
return []string{"pwd"}
}
return nil
@@ -102,7 +102,7 @@ func (a *AuthRequest) GetSubject() string {
}
func (a *AuthRequest) Done() bool {
- return a.passwordChecked // this example only uses password for authentication
+ return a.done
}
func PromptToInternal(oidcPrompt oidc.SpaceDelimitedArray) []string {
diff --git a/example/server/storage/storage.go b/example/server/storage/storage.go
index ff7889e..7e1afbd 100644
--- a/example/server/storage/storage.go
+++ b/example/server/storage/storage.go
@@ -28,8 +28,8 @@ var serviceKey1 = &rsa.PublicKey{
E: 65537,
}
-// var _ op.Storage = &storage{}
-// var _ op.ClientCredentialsStorage = &storage{}
+var _ op.Storage = &Storage{}
+var _ op.ClientCredentialsStorage = &Storage{}
// storage implements the op.Storage interface
// typically you would implement this as a layer on top of your database
@@ -46,6 +46,7 @@ type Storage struct {
signingKey signingKey
deviceCodes map[string]deviceAuthorizationEntry
userCodes map[string]string
+ serviceUsers map[string]*Client
}
type signingKey struct {
@@ -109,6 +110,16 @@ func NewStorage(userStore UserStore) *Storage {
},
deviceCodes: make(map[string]deviceAuthorizationEntry),
userCodes: make(map[string]string),
+ serviceUsers: map[string]*Client{
+ "sid1": {
+ id: "sid1",
+ secret: "verysecret",
+ grantTypes: []oidc.GrantType{
+ oidc.GrantTypeClientCredentials,
+ },
+ accessTokenType: op.AccessTokenTypeBearer,
+ },
+ },
}
}
@@ -133,7 +144,7 @@ func (s *Storage) CheckUsernamePassword(username, password, id string) error {
// you will have to change some state on the request to guide the user through possible multiple steps of the login process
// in this example we'll simply check the username / password and set a boolean to true
// therefore we will also just check this boolean if the request / login has been finished
- request.passwordChecked = true
+ request.done = true
return nil
}
return fmt.Errorf("username or password wrong")
@@ -847,3 +858,44 @@ func (s *Storage) DenyDeviceAuthorization(ctx context.Context, userCode string)
s.deviceCodes[s.userCodes[userCode]].state.Denied = true
return nil
}
+
+// AuthRequestDone is used by testing and is not required to implement op.Storage
+func (s *Storage) AuthRequestDone(id string) error {
+ s.lock.Lock()
+ defer s.lock.Unlock()
+
+ if req, ok := s.authRequests[id]; ok {
+ req.done = true
+ return nil
+ }
+
+ return errors.New("request not found")
+}
+
+func (s *Storage) ClientCredentials(ctx context.Context, clientID, clientSecret string) (op.Client, error) {
+ s.lock.Lock()
+ defer s.lock.Unlock()
+
+ client, ok := s.serviceUsers[clientID]
+ if !ok {
+ return nil, errors.New("wrong service user or password")
+ }
+ if client.secret != clientSecret {
+ return nil, errors.New("wrong service user or password")
+ }
+
+ return client, nil
+}
+
+func (s *Storage) ClientCredentialsTokenRequest(ctx context.Context, clientID string, scopes []string) (op.TokenRequest, error) {
+ client, ok := s.serviceUsers[clientID]
+ if !ok {
+ return nil, errors.New("wrong service user or password")
+ }
+
+ return &oidc.JWTTokenRequest{
+ Subject: client.id,
+ Audience: []string{clientID},
+ Scopes: scopes,
+ }, nil
+}
diff --git a/go.mod b/go.mod
index 9ed1e8e..50167f7 100644
--- a/go.mod
+++ b/go.mod
@@ -10,7 +10,7 @@ require (
github.com/gorilla/schema v1.2.0
github.com/gorilla/securecookie v1.1.1
github.com/jeremija/gosubmit v0.2.7
- github.com/muhlemmer/gu v0.3.0
+ github.com/muhlemmer/gu v0.3.1
github.com/rs/cors v1.8.3
github.com/sirupsen/logrus v1.9.0
github.com/stretchr/testify v1.8.1
diff --git a/go.sum b/go.sum
index 1933228..a5cf579 100644
--- a/go.sum
+++ b/go.sum
@@ -123,8 +123,8 @@ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORN
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
-github.com/muhlemmer/gu v0.3.0 h1:UwNv9xXGp1WDgHKgk7ljjh3duh1w4ZAY1k1NsWBYl3Y=
-github.com/muhlemmer/gu v0.3.0/go.mod h1:YHtHR+gxM+bKEIIs7Hmi9sPT3ZDUvTN/i88wQpZkrdM=
+github.com/muhlemmer/gu v0.3.1 h1:7EAqmFrW7n3hETvuAdmFmn4hS8W+z3LgKtrnow+YzNM=
+github.com/muhlemmer/gu v0.3.1/go.mod h1:YHtHR+gxM+bKEIIs7Hmi9sPT3ZDUvTN/i88wQpZkrdM=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
diff --git a/pkg/op/context.go b/pkg/op/context.go
index 4406273..7cff5a7 100644
--- a/pkg/op/context.go
+++ b/pkg/op/context.go
@@ -7,16 +7,16 @@ import (
type key int
-var (
- issuer key = 0
+const (
+ issuerKey key = 0
)
type IssuerInterceptor struct {
issuerFromRequest IssuerFromRequest
}
-//NewIssuerInterceptor will set the issuer into the context
-//by the provided IssuerFromRequest (e.g. returned from StaticIssuer or IssuerFromHost)
+// NewIssuerInterceptor will set the issuer into the context
+// by the provided IssuerFromRequest (e.g. returned from StaticIssuer or IssuerFromHost)
func NewIssuerInterceptor(issuerFromRequest IssuerFromRequest) *IssuerInterceptor {
return &IssuerInterceptor{
issuerFromRequest: issuerFromRequest,
@@ -35,15 +35,19 @@ func (i *IssuerInterceptor) HandlerFunc(next http.HandlerFunc) http.HandlerFunc
}
}
-//IssuerFromContext reads the issuer from the context (set by an IssuerInterceptor)
-//it will return an empty string if not found
+// IssuerFromContext reads the issuer from the context (set by an IssuerInterceptor)
+// it will return an empty string if not found
func IssuerFromContext(ctx context.Context) string {
- ctxIssuer, _ := ctx.Value(issuer).(string)
+ ctxIssuer, _ := ctx.Value(issuerKey).(string)
return ctxIssuer
}
+// ContextWithIssuer returns a new context with issuer set to it.
+func ContextWithIssuer(ctx context.Context, issuer string) context.Context {
+ return context.WithValue(ctx, issuerKey, issuer)
+}
+
func (i *IssuerInterceptor) setIssuerCtx(w http.ResponseWriter, r *http.Request, next http.Handler) {
- ctx := context.WithValue(r.Context(), issuer, i.issuerFromRequest(r))
- r = r.WithContext(ctx)
+ r = r.WithContext(ContextWithIssuer(r.Context(), i.issuerFromRequest(r)))
next.ServeHTTP(w, r)
}
diff --git a/pkg/op/device_test.go b/pkg/op/device_test.go
index de16a59..69ba102 100644
--- a/pkg/op/device_test.go
+++ b/pkg/op/device_test.go
@@ -3,7 +3,6 @@ package op_test
import (
"context"
"crypto/rand"
- "crypto/sha256"
"encoding/base64"
"io"
mr "math/rand"
@@ -16,52 +15,10 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
- "github.com/zitadel/oidc/v2/example/server/storage"
"github.com/zitadel/oidc/v2/pkg/oidc"
"github.com/zitadel/oidc/v2/pkg/op"
- "golang.org/x/text/language"
)
-var testProvider op.OpenIDProvider
-
-const (
- testIssuer = "https://localhost:9998/"
- pathLoggedOut = "/logged-out"
-)
-
-func init() {
- config := &op.Config{
- CryptoKey: sha256.Sum256([]byte("test")),
- DefaultLogoutRedirectURI: pathLoggedOut,
- CodeMethodS256: true,
- AuthMethodPost: true,
- AuthMethodPrivateKeyJWT: true,
- GrantTypeRefreshToken: true,
- RequestObjectSupported: true,
- SupportedUILocales: []language.Tag{language.English},
- DeviceAuthorization: op.DeviceAuthorizationConfig{
- Lifetime: 5 * time.Minute,
- PollInterval: 5 * time.Second,
- UserFormURL: testIssuer + "device",
- UserCode: op.UserCodeBase20,
- },
- }
-
- storage.RegisterClients(
- storage.NativeClient("native"),
- storage.WebClient("web", "secret"),
- storage.WebClient("api", "secret"),
- )
-
- var err error
- testProvider, err = op.NewOpenIDProvider(testIssuer, config,
- storage.NewStorage(storage.NewUserStore(testIssuer)), op.WithAllowInsecure(),
- )
- if err != nil {
- panic(err)
- }
-}
-
func Test_deviceAuthorizationHandler(t *testing.T) {
req := &oidc.DeviceAuthorizationRequest{
Scopes: []string{"foo", "bar"},
diff --git a/pkg/op/op_test.go b/pkg/op/op_test.go
new file mode 100644
index 0000000..ba3570b
--- /dev/null
+++ b/pkg/op/op_test.go
@@ -0,0 +1,392 @@
+package op_test
+
+import (
+ "context"
+ "crypto/sha256"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/muhlemmer/gu"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "github.com/zitadel/oidc/v2/example/server/storage"
+ "github.com/zitadel/oidc/v2/pkg/oidc"
+ "github.com/zitadel/oidc/v2/pkg/op"
+ "golang.org/x/text/language"
+)
+
+var testProvider op.OpenIDProvider
+
+const (
+ testIssuer = "https://localhost:9998/"
+ pathLoggedOut = "/logged-out"
+)
+
+func init() {
+ config := &op.Config{
+ CryptoKey: sha256.Sum256([]byte("test")),
+ DefaultLogoutRedirectURI: pathLoggedOut,
+ CodeMethodS256: true,
+ AuthMethodPost: true,
+ AuthMethodPrivateKeyJWT: true,
+ GrantTypeRefreshToken: true,
+ RequestObjectSupported: true,
+ SupportedUILocales: []language.Tag{language.English},
+ DeviceAuthorization: op.DeviceAuthorizationConfig{
+ Lifetime: 5 * time.Minute,
+ PollInterval: 5 * time.Second,
+ UserFormURL: testIssuer + "device",
+ UserCode: op.UserCodeBase20,
+ },
+ }
+
+ storage.RegisterClients(
+ storage.NativeClient("native"),
+ storage.WebClient("web", "secret", "https://example.com"),
+ storage.WebClient("api", "secret"),
+ )
+
+ var err error
+ testProvider, err = op.NewOpenIDProvider(testIssuer, config,
+ storage.NewStorage(storage.NewUserStore(testIssuer)), op.WithAllowInsecure(),
+ )
+ if err != nil {
+ panic(err)
+ }
+}
+
+type routesTestStorage interface {
+ op.Storage
+ AuthRequestDone(id string) error
+}
+
+func mapAsValues(m map[string]string) string {
+ values := make(url.Values, len(m))
+ for k, v := range m {
+ values.Set(k, v)
+ }
+ return values.Encode()
+}
+
+func TestRoutes(t *testing.T) {
+ storage := testProvider.Storage().(routesTestStorage)
+ ctx := op.ContextWithIssuer(context.Background(), testIssuer)
+
+ client, err := storage.GetClientByClientID(ctx, "web")
+ require.NoError(t, err)
+
+ oidcAuthReq := &oidc.AuthRequest{
+ ClientID: client.GetID(),
+ RedirectURI: "https://example.com",
+ MaxAge: gu.Ptr[uint](300),
+ Scopes: oidc.SpaceDelimitedArray{oidc.ScopeOpenID, oidc.ScopeOfflineAccess, oidc.ScopeEmail, oidc.ScopeProfile, oidc.ScopePhone},
+ ResponseType: oidc.ResponseTypeCode,
+ }
+
+ authReq, err := storage.CreateAuthRequest(ctx, oidcAuthReq, "id1")
+ require.NoError(t, err)
+ storage.AuthRequestDone(authReq.GetID())
+
+ accessToken, refreshToken, _, err := op.CreateAccessToken(ctx, authReq, op.AccessTokenTypeBearer, testProvider, client, "")
+ require.NoError(t, err)
+ accessTokenRevoke, _, _, err := op.CreateAccessToken(ctx, authReq, op.AccessTokenTypeBearer, testProvider, client, "")
+ require.NoError(t, err)
+ idToken, err := op.CreateIDToken(ctx, testIssuer, authReq, time.Hour, accessToken, "123", storage, client)
+ require.NoError(t, err)
+ jwtToken, _, _, err := op.CreateAccessToken(ctx, authReq, op.AccessTokenTypeJWT, testProvider, client, "")
+ require.NoError(t, err)
+
+ oidcAuthReq.IDTokenHint = idToken
+
+ serverURL, err := url.Parse(testIssuer)
+ require.NoError(t, err)
+
+ type basicAuth struct {
+ username, password string
+ }
+
+ tests := []struct {
+ name string
+ method string
+ path string
+ basicAuth *basicAuth
+ header map[string]string
+ values map[string]string
+ body map[string]string
+ wantCode int
+ headerContains map[string]string
+ json string // test for exact json output
+ contains []string // when the body output is not constant, we just check for snippets to be present in the response
+ }{
+ {
+ name: "health",
+ method: http.MethodGet,
+ path: "/healthz",
+ wantCode: http.StatusOK,
+ json: `{"status":"ok"}`,
+ },
+ {
+ name: "ready",
+ method: http.MethodGet,
+ path: "/ready",
+ wantCode: http.StatusOK,
+ json: `{"status":"ok"}`,
+ },
+ {
+ name: "discovery",
+ method: http.MethodGet,
+ path: oidc.DiscoveryEndpoint,
+ wantCode: http.StatusOK,
+ json: `{"issuer":"https://localhost:9998/","authorization_endpoint":"https://localhost:9998/authorize","token_endpoint":"https://localhost:9998/oauth/token","introspection_endpoint":"https://localhost:9998/oauth/introspect","userinfo_endpoint":"https://localhost:9998/userinfo","revocation_endpoint":"https://localhost:9998/revoke","end_session_endpoint":"https://localhost:9998/end_session","device_authorization_endpoint":"https://localhost:9998/device_authorization","jwks_uri":"https://localhost:9998/keys","scopes_supported":["openid","profile","email","phone","address","offline_access"],"response_types_supported":["code","id_token","id_token token"],"grant_types_supported":["authorization_code","implicit","refresh_token","client_credentials","urn:ietf:params:oauth:grant-type:token-exchange","urn:ietf:params:oauth:grant-type:jwt-bearer","urn:ietf:params:oauth:grant-type:device_code"],"subject_types_supported":["public"],"id_token_signing_alg_values_supported":["RS256"],"request_object_signing_alg_values_supported":["RS256"],"token_endpoint_auth_methods_supported":["none","client_secret_basic","client_secret_post","private_key_jwt"],"token_endpoint_auth_signing_alg_values_supported":["RS256"],"revocation_endpoint_auth_methods_supported":["none","client_secret_basic","client_secret_post","private_key_jwt"],"revocation_endpoint_auth_signing_alg_values_supported":["RS256"],"introspection_endpoint_auth_methods_supported":["client_secret_basic","private_key_jwt"],"introspection_endpoint_auth_signing_alg_values_supported":["RS256"],"claims_supported":["sub","aud","exp","iat","iss","auth_time","nonce","acr","amr","c_hash","at_hash","act","scopes","client_id","azp","preferred_username","name","family_name","given_name","locale","email","email_verified","phone_number","phone_number_verified"],"code_challenge_methods_supported":["S256"],"ui_locales_supported":["en"],"request_parameter_supported":true,"request_uri_parameter_supported":false}`,
+ },
+ {
+ name: "authorization",
+ method: http.MethodGet,
+ path: testProvider.AuthorizationEndpoint().Relative(),
+ values: map[string]string{
+ "client_id": client.GetID(),
+ "redirect_uri": "https://example.com",
+ "scope": oidc.SpaceDelimitedArray{oidc.ScopeOpenID, oidc.ScopeOfflineAccess}.Encode(),
+ "response_type": string(oidc.ResponseTypeCode),
+ },
+ wantCode: http.StatusFound,
+ headerContains: map[string]string{"Location": "/login/username?authRequestID="},
+ },
+ {
+ name: "authorization callback",
+ method: http.MethodGet,
+ path: testProvider.AuthorizationEndpoint().Relative() + "/callback",
+ values: map[string]string{"id": authReq.GetID()},
+ wantCode: http.StatusFound,
+ headerContains: map[string]string{"Location": "https://example.com?code="},
+ contains: []string{
+ `Found.",
+ },
+ },
+ {
+ // This call will fail. A successfull test is already
+ // part of client/integration_test.go
+ name: "code exchange",
+ method: http.MethodGet,
+ path: testProvider.TokenEndpoint().Relative(),
+ values: map[string]string{
+ "grant_type": string(oidc.GrantTypeCode),
+ "code": "123",
+ },
+ wantCode: http.StatusUnauthorized,
+ json: `{"error":"invalid_client"}`,
+ },
+ {
+ name: "JWT authorization",
+ method: http.MethodGet,
+ path: testProvider.TokenEndpoint().Relative(),
+ values: map[string]string{
+ "grant_type": string(oidc.GrantTypeBearer),
+ "scope": oidc.SpaceDelimitedArray{oidc.ScopeOpenID, oidc.ScopeOfflineAccess}.Encode(),
+ "assertion": jwtToken,
+ },
+ wantCode: http.StatusBadRequest,
+ json: "{\"error\":\"server_error\",\"error_description\":\"audience is not valid: Audience must contain client_id \\\"https://localhost:9998/\\\"\"}",
+ },
+ {
+ name: "Token exchange",
+ method: http.MethodGet,
+ path: testProvider.TokenEndpoint().Relative(),
+ basicAuth: &basicAuth{"web", "secret"},
+ values: map[string]string{
+ "grant_type": string(oidc.GrantTypeTokenExchange),
+ "scope": oidc.SpaceDelimitedArray{oidc.ScopeOpenID, oidc.ScopeOfflineAccess}.Encode(),
+ "subject_token": jwtToken,
+ "subject_token_type": string(oidc.AccessTokenType),
+ },
+ wantCode: http.StatusOK,
+ contains: []string{
+ `{"access_token":"`,
+ `","issued_token_type":"urn:ietf:params:oauth:token-type:refresh_token","token_type":"Bearer","expires_in":299,"scope":"openid offline_access","refresh_token":"`,
+ },
+ },
+ {
+ name: "Client credentials exchange",
+ method: http.MethodGet,
+ path: testProvider.TokenEndpoint().Relative(),
+ basicAuth: &basicAuth{"sid1", "verysecret"},
+ values: map[string]string{
+ "grant_type": string(oidc.GrantTypeClientCredentials),
+ "scope": oidc.SpaceDelimitedArray{oidc.ScopeOpenID, oidc.ScopeOfflineAccess}.Encode(),
+ },
+ wantCode: http.StatusOK,
+ contains: []string{`{"access_token":"`, `","token_type":"Bearer","expires_in":299}`},
+ },
+ {
+ // This call will fail. A successfull test is already
+ // part of device_test.go
+ name: "device token",
+ method: http.MethodPost,
+ path: testProvider.TokenEndpoint().Relative(),
+ basicAuth: &basicAuth{"web", "secret"},
+ header: map[string]string{
+ "Content-Type": "application/x-www-form-urlencoded",
+ },
+ body: map[string]string{
+ "grant_type": string(oidc.GrantTypeDeviceCode),
+ "device_code": "123",
+ },
+ wantCode: http.StatusBadRequest,
+ json: `{"error":"access_denied","error_description":"The authorization request was denied."}`,
+ },
+ {
+ name: "missing grant type",
+ method: http.MethodGet,
+ path: testProvider.TokenEndpoint().Relative(),
+ wantCode: http.StatusBadRequest,
+ json: `{"error":"invalid_request","error_description":"grant_type missing"}`,
+ },
+ {
+ name: "unsupported grant type",
+ method: http.MethodGet,
+ path: testProvider.TokenEndpoint().Relative(),
+ values: map[string]string{
+ "grant_type": "foo",
+ },
+ wantCode: http.StatusBadRequest,
+ json: `{"error":"unsupported_grant_type","error_description":"foo not supported"}`,
+ },
+ {
+ name: "introspection",
+ method: http.MethodGet,
+ path: testProvider.IntrospectionEndpoint().Relative(),
+ basicAuth: &basicAuth{"web", "secret"},
+ values: map[string]string{
+ "token": accessToken,
+ },
+ wantCode: http.StatusOK,
+ json: `{"active":true,"scope":"openid offline_access email profile phone","client_id":"web","sub":"id1","username":"test-user@localhost","name":"Test User","given_name":"Test","family_name":"User","locale":"de","preferred_username":"test-user@localhost","email":"test-user@zitadel.ch","email_verified":true}`,
+ },
+ {
+ name: "user info",
+ method: http.MethodGet,
+ path: testProvider.UserinfoEndpoint().Relative(),
+ header: map[string]string{
+ "authorization": "Bearer " + accessToken,
+ },
+ wantCode: http.StatusOK,
+ json: `{"sub":"id1","name":"Test User","given_name":"Test","family_name":"User","locale":"de","preferred_username":"test-user@localhost","email":"test-user@zitadel.ch","email_verified":true}`,
+ },
+ {
+ name: "refresh token",
+ method: http.MethodGet,
+ path: testProvider.TokenEndpoint().Relative(),
+ values: map[string]string{
+ "grant_type": string(oidc.GrantTypeRefreshToken),
+ "refresh_token": refreshToken,
+ "client_id": client.GetID(),
+ "client_secret": "secret",
+ },
+ wantCode: http.StatusOK,
+ contains: []string{
+ `{"access_token":"`,
+ `","token_type":"Bearer","refresh_token":"`,
+ `","expires_in":299,"id_token":"`,
+ },
+ },
+ {
+ name: "revoke",
+ method: http.MethodGet,
+ path: testProvider.RevocationEndpoint().Relative(),
+ basicAuth: &basicAuth{"web", "secret"},
+ values: map[string]string{
+ "token": accessTokenRevoke,
+ },
+ wantCode: http.StatusOK,
+ },
+ {
+ name: "end session",
+ method: http.MethodGet,
+ path: testProvider.EndSessionEndpoint().Relative(),
+ values: map[string]string{
+ "id_token_hint": idToken,
+ "client_id": "web",
+ },
+ wantCode: http.StatusFound,
+ headerContains: map[string]string{"Location": "/logged-out"},
+ contains: []string{`Found.`},
+ },
+ {
+ name: "keys",
+ method: http.MethodGet,
+ path: testProvider.KeysEndpoint().Relative(),
+ wantCode: http.StatusOK,
+ contains: []string{
+ `{"keys":[{"use":"sig","kty":"RSA","kid":"`,
+ `","alg":"RS256","n":"`, `","e":"AQAB"}]}`,
+ },
+ },
+ {
+ name: "device authorization",
+ method: http.MethodGet,
+ path: testProvider.DeviceAuthorizationEndpoint().Relative(),
+ basicAuth: &basicAuth{"web", "secret"},
+ values: map[string]string{
+ "scope": oidc.SpaceDelimitedArray{oidc.ScopeOpenID, oidc.ScopeOfflineAccess}.Encode(),
+ },
+ wantCode: http.StatusOK,
+ contains: []string{
+ `{"device_code":"`, `","user_code":"`,
+ `","verification_uri":"https://localhost:9998/device"`,
+ `"verification_uri_complete":"https://localhost:9998/device?user_code=`,
+ `","expires_in":300,"interval":5}`,
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ u := gu.PtrCopy(serverURL)
+ u.Path = tt.path
+ if tt.values != nil {
+ u.RawQuery = mapAsValues(tt.values)
+ }
+ var body io.Reader
+ if tt.body != nil {
+ body = strings.NewReader(mapAsValues(tt.body))
+ }
+
+ req := httptest.NewRequest(tt.method, u.String(), body)
+ for k, v := range tt.header {
+ req.Header.Set(k, v)
+ }
+ if tt.basicAuth != nil {
+ req.SetBasicAuth(tt.basicAuth.username, tt.basicAuth.password)
+ }
+
+ rec := httptest.NewRecorder()
+ testProvider.HttpHandler().ServeHTTP(rec, req)
+
+ resp := rec.Result()
+ require.NoError(t, err)
+ assert.Equal(t, tt.wantCode, resp.StatusCode)
+
+ respBody, err := io.ReadAll(resp.Body)
+ require.NoError(t, err)
+
+ respBodyString := string(respBody)
+ t.Log(respBodyString)
+ t.Log(resp.Header)
+
+ if tt.json != "" {
+ assert.JSONEq(t, tt.json, respBodyString)
+ }
+ for _, c := range tt.contains {
+ assert.Contains(t, respBodyString, c)
+ }
+ for k, v := range tt.headerContains {
+ assert.Contains(t, resp.Header.Get(k), v)
+ }
+ })
+ }
+}