397 lines
14 KiB
Go
397 lines
14 KiB
Go
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/v3/example/server/storage"
|
|
"github.com/zitadel/oidc/v3/pkg/oidc"
|
|
"github.com/zitadel/oidc/v3/pkg/op"
|
|
"golang.org/x/text/language"
|
|
)
|
|
|
|
var (
|
|
testProvider op.OpenIDProvider
|
|
testConfig = &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,
|
|
UserFormPath: "/device",
|
|
UserCode: op.UserCodeBase20,
|
|
},
|
|
}
|
|
)
|
|
|
|
const (
|
|
testIssuer = "https://localhost:9998/"
|
|
pathLoggedOut = "/logged-out"
|
|
)
|
|
|
|
func init() {
|
|
storage.RegisterClients(
|
|
storage.NativeClient("native"),
|
|
storage.WebClient("web", "secret", "https://example.com"),
|
|
storage.WebClient("api", "secret"),
|
|
)
|
|
|
|
testProvider = newTestProvider(testConfig)
|
|
}
|
|
|
|
func newTestProvider(config *op.Config) op.OpenIDProvider {
|
|
provider, err := op.NewOpenIDProvider(testIssuer, config,
|
|
storage.NewStorage(storage.NewUserStore(testIssuer)), op.WithAllowInsecure(),
|
|
)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return provider
|
|
}
|
|
|
|
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{
|
|
`<a href="https://example.com?code=`,
|
|
">Found</a>.",
|
|
},
|
|
},
|
|
{
|
|
// 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{`<a href="/logged-out">Found</a>.`},
|
|
},
|
|
{
|
|
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.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)
|
|
}
|
|
})
|
|
}
|
|
}
|