Skip to content

Commit 9ef1eae

Browse files
authored
feat: add management api for client credentials (#329)
1 parent aa29ee4 commit 9ef1eae

File tree

12 files changed

+1405
-80
lines changed

12 files changed

+1405
-80
lines changed

diode-server/auth/errors.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package auth
2+
3+
// Error auth service error
4+
type Error struct {
5+
Message string `json:"message"`
6+
StatusCode int `json:"status_code"`
7+
}
8+
9+
func (e *Error) Error() string {
10+
return e.Message
11+
}
12+
13+
// NewAuthError creates a new auth service error
14+
func NewAuthError(message string, statusCode int) *Error {
15+
return &Error{
16+
Message: message,
17+
StatusCode: statusCode,
18+
}
19+
}

diode-server/auth/manager.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,28 @@ import (
44
"context"
55
"crypto/rand"
66
"encoding/base64"
7+
"encoding/hex"
8+
"fmt"
9+
"strings"
10+
11+
"github.com/gosimple/slug"
712
)
813

914
// ClientInfo is a struct that contains information about a client.
1015
type ClientInfo struct {
1116
ClientID string `json:"client_id"`
1217
Scope string `json:"scope"`
1318
ClientSecret string `json:"client_secret,omitempty"`
19+
Owner string `json:"owner,omitempty"`
20+
ClientName string `json:"client_name,omitempty"`
21+
CreatedAt string `json:"created_at,omitempty"`
1422
}
1523

1624
// RetrieveClientsRequest is a struct that contains information about a request to retrieve clients.
1725
type RetrieveClientsRequest struct {
26+
Owner string
1827
PageToken string
28+
PageSize int
1929
}
2030

2131
// RetrieveClientsResponse reponse struct for listing clients
@@ -44,3 +54,21 @@ func GenerateClientSecret() (string, error) {
4454
}
4555
return base64.StdEncoding.EncodeToString(secret), nil
4656
}
57+
58+
// GenerateClientID generates a ClientID for a client.
59+
func GenerateClientID(clientInfo ClientInfo) (string, error) {
60+
clientSlug := slug.Make(clientInfo.ClientName)
61+
if len(clientSlug) > 15 {
62+
clientSlug = clientSlug[:15]
63+
}
64+
clientSlug = strings.Trim(clientSlug, "-")
65+
66+
// generate a random 64 bit identifier
67+
randomID := make([]byte, 8)
68+
if _, err := rand.Read(randomID); err != nil {
69+
return "", err
70+
}
71+
randomIDStr := hex.EncodeToString(randomID)
72+
73+
return fmt.Sprintf("%s-%s", clientSlug, randomIDStr), nil
74+
}

diode-server/auth/manager_hydra.go

Lines changed: 80 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,18 @@ import (
77
"net/http"
88
"net/url"
99
"strings"
10+
"time"
1011

1112
hydra "github.com/ory/hydra-client-go/v2"
1213
)
1314

15+
const (
16+
hydraAuthMethodClientSecretPost = "client_secret_post"
17+
hydraGrantTypeClientCredentials = "client_credentials"
18+
hydraResponseTypeToken = "token"
19+
hydraClientFormatJSON = "json"
20+
)
21+
1422
// HydraClientManager is a ClientManager for a hydra server
1523
type HydraClientManager struct {
1624
hydraAdmin *hydra.APIClient
@@ -34,14 +42,23 @@ func NewHydraClientManager(adminURL string, logger *slog.Logger) *HydraClientMan
3442

3543
// CreateClient creates a new client
3644
func (h *HydraClientManager) CreateClient(ctx context.Context, clientInfo ClientInfo) (ClientInfo, error) {
45+
authMethod := hydraAuthMethodClientSecretPost
3746
newClient := hydra.OAuth2Client{
38-
ClientId: &clientInfo.ClientID,
39-
Scope: &clientInfo.Scope,
40-
GrantTypes: []string{"client_credentials"},
47+
ClientId: &clientInfo.ClientID,
48+
Scope: &clientInfo.Scope,
49+
GrantTypes: []string{hydraGrantTypeClientCredentials},
50+
TokenEndpointAuthMethod: &authMethod,
51+
ResponseTypes: []string{hydraResponseTypeToken},
4152
}
4253
if clientInfo.ClientSecret != "" {
4354
newClient.ClientSecret = &clientInfo.ClientSecret
4455
}
56+
if clientInfo.Owner != "" {
57+
newClient.Owner = &clientInfo.Owner
58+
}
59+
if clientInfo.ClientName != "" {
60+
newClient.ClientName = &clientInfo.ClientName
61+
}
4562

4663
createdClient, response, err := h.hydraAdmin.OAuth2API.CreateOAuth2Client(ctx).OAuth2Client(newClient).Execute()
4764
if response != nil {
@@ -51,13 +68,13 @@ func (h *HydraClientManager) CreateClient(ctx context.Context, clientInfo Client
5168
}
5269
}()
5370
if response.StatusCode == 409 {
54-
return ClientInfo{}, fmt.Errorf("failed to create client: client with id %s already exists", *newClient.ClientId)
71+
return ClientInfo{}, NewAuthError(fmt.Sprintf("failed to create client: client with id %s already exists", *newClient.ClientId), http.StatusConflict)
5572
}
5673
if response.StatusCode == 400 {
57-
return ClientInfo{}, fmt.Errorf("failed to create client: invalid request")
74+
return ClientInfo{}, NewAuthError("failed to create client: invalid request", http.StatusBadRequest)
5875
}
5976
if response.StatusCode != 201 {
60-
return ClientInfo{}, fmt.Errorf("failed to create client: status=%s", response.Status)
77+
return ClientInfo{}, NewAuthError("failed to create client", response.StatusCode)
6178
}
6279
}
6380
// these can be confusing and related to internal client failures, so handled after http status codes
@@ -78,10 +95,10 @@ func (h *HydraClientManager) DeleteClientByID(ctx context.Context, clientID stri
7895
}
7996
}()
8097
if response.StatusCode == 404 {
81-
return fmt.Errorf("client %s not found", clientID)
98+
return NewAuthError(fmt.Sprintf("client %s not found", clientID), http.StatusNotFound)
8299
}
83100
if response.StatusCode != 204 {
84-
return fmt.Errorf("failed to delete client from hydra: status=%s", response.Status)
101+
return NewAuthError("failed to delete client from hydra", response.StatusCode)
85102
}
86103
}
87104
// these can be confusing and related to internal client failures, so handled after http status codes
@@ -102,10 +119,10 @@ func (h *HydraClientManager) RetrieveClientByID(ctx context.Context, clientID st
102119
}
103120
}()
104121
if response.StatusCode == 404 {
105-
return ClientInfo{}, fmt.Errorf("client %s not found", clientID)
122+
return ClientInfo{}, NewAuthError(fmt.Sprintf("client %s not found", clientID), http.StatusNotFound)
106123
}
107124
if response.StatusCode != 200 {
108-
return ClientInfo{}, fmt.Errorf("failed to retrieve client: status=%s", response.Status)
125+
return ClientInfo{}, NewAuthError("failed to retrieve client", response.StatusCode)
109126
}
110127
}
111128
// these tend to be confusing and related to internal client failures, so handled after http status codes
@@ -120,15 +137,26 @@ func (h *HydraClientManager) RetrieveClientByID(ctx context.Context, clientID st
120137
func (h *HydraClientManager) RetrieveClients(ctx context.Context, q RetrieveClientsRequest) (RetrieveClientsResponse, error) {
121138
var out RetrieveClientsResponse
122139

123-
clients, response, err := h.hydraAdmin.OAuth2API.ListOAuth2Clients(ctx).PageToken(q.PageToken).Execute()
140+
req := h.hydraAdmin.OAuth2API.ListOAuth2Clients(ctx)
141+
if q.Owner != "" {
142+
req = req.Owner(q.Owner)
143+
}
144+
if q.PageToken != "" {
145+
req = req.PageToken(q.PageToken)
146+
}
147+
if q.PageSize > 0 {
148+
req = req.PageSize(int64(q.PageSize))
149+
}
150+
151+
clients, response, err := req.Execute()
124152
if response != nil {
125153
defer func() {
126154
if err := response.Body.Close(); err != nil {
127155
h.logger.Error("failed to close response body", "error", err)
128156
}
129157
}()
130158
if response.StatusCode != 200 {
131-
return out, fmt.Errorf("failed to retrieve clients: status=%s", response.Status)
159+
return out, NewAuthError("failed to retrieve clients", response.StatusCode)
132160
}
133161
}
134162
if err != nil {
@@ -150,43 +178,62 @@ func clientInfoFromHydraClient(client *hydra.OAuth2Client) ClientInfo {
150178
if client == nil {
151179
return clientInfo
152180
}
153-
154181
if client.ClientId != nil {
155182
clientInfo.ClientID = *client.ClientId
156183
}
157184
if client.Scope != nil {
158185
clientInfo.Scope = *client.Scope
159186
}
160-
187+
if client.Owner != nil {
188+
clientInfo.Owner = *client.Owner
189+
}
190+
if client.ClientName != nil {
191+
clientInfo.ClientName = *client.ClientName
192+
}
193+
if client.CreatedAt != nil {
194+
clientInfo.CreatedAt = client.CreatedAt.Format(time.RFC3339)
195+
}
196+
if client.ClientSecret != nil {
197+
clientInfo.ClientSecret = *client.ClientSecret
198+
}
161199
return clientInfo
162200
}
163201

164202
func getHydraNextPageToken(response *http.Response, logger *slog.Logger) string {
165203
for _, linkHeader := range response.Header.Values("Link") {
166-
params := strings.Split(linkHeader, ";")
167-
link := params[0]
168-
params = params[1:]
169-
// search for rel="next"
170-
for _, param := range params {
171-
vs := strings.Split(param, "=")
172-
if len(vs) != 2 {
204+
links := strings.Split(linkHeader, ",")
205+
for _, link := range links {
206+
params := strings.Split(link, ";")
207+
if len(params) < 2 {
173208
continue
174209
}
175-
k, v := strings.TrimSpace(vs[0]), strings.TrimSpace(vs[1])
176-
if k == "rel" && (v == "next" || v == "\"next\"") {
177-
parsedURL, err := url.Parse(link)
178-
if err != nil {
179-
logger.Warn("failed to parse url in rel=next link", "error", err, "link", linkHeader)
180-
return ""
210+
link := params[0]
211+
params = params[1:]
212+
// search for rel="next"
213+
for _, param := range params {
214+
vs := strings.Split(param, "=")
215+
if len(vs) != 2 {
216+
continue
181217
}
182-
queryParams := parsedURL.Query()
183-
for key, values := range queryParams {
184-
if key == "page_token" {
185-
return values[0]
218+
k, v := strings.TrimSpace(vs[0]), strings.TrimSpace(vs[1])
219+
if k == "rel" && (v == "next" || v == "\"next\"") {
220+
link = strings.TrimPrefix(link, "<")
221+
link = strings.TrimSuffix(link, ">")
222+
parsedURL, err := url.Parse(link)
223+
if err != nil {
224+
logger.Warn("failed to parse url in rel=next link", "error", err, "link", linkHeader)
225+
return ""
186226
}
227+
queryParams := parsedURL.Query()
228+
for key, values := range queryParams {
229+
if key == "page_token" {
230+
logger.Info("found next page token", "token", values[0])
231+
return values[0]
232+
}
233+
}
234+
logger.Warn("failed to find next page token in rel=next url", "link", linkHeader)
235+
return ""
187236
}
188-
logger.Warn("failed to find next page token in rel=next url", "link", linkHeader)
189-
return ""
190237
}
191238
}
192239
}

diode-server/auth/manager_hydra_integration_test.go

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,14 @@ import (
1717
"github.com/netboxlabs/diode/diode-server/auth"
1818
)
1919

20-
type logDumpConsumer struct{}
20+
type logDumpConsumer struct {
21+
enabled bool
22+
}
2123

2224
func (c *logDumpConsumer) Accept(log testcontainers.Log) {
25+
if !c.enabled {
26+
return
27+
}
2328
fmt.Printf("HYDRA LOG: %s\n", string(log.Content))
2429
}
2530

@@ -58,7 +63,6 @@ func TestHydraClientManager(t *testing.T) {
5863
authEndpoint, err := hydraC.Endpoint(ctx, "")
5964
require.NoError(t, err)
6065
authEndpoint = fmt.Sprintf("http://%s", authEndpoint)
61-
fmt.Printf("HYDRA ENDPOINT: %s\n", authEndpoint)
6266
manager = auth.NewHydraClientManager(authEndpoint, logger)
6367

6468
// no client initially
@@ -75,7 +79,7 @@ func TestHydraClientManager(t *testing.T) {
7579
require.NoError(t, err)
7680
require.NotNil(t, createdClient)
7781
require.Equal(t, "diode-test-client-1", createdClient.ClientID)
78-
require.Equal(t, "", createdClient.ClientSecret)
82+
require.Equal(t, "secret-material", createdClient.ClientSecret)
7983
require.Equal(t, "test:diode:1 test:diode:2", createdClient.Scope)
8084

8185
// fetch the client by id

diode-server/auth/mocks/tokenownershipprovider.go

Lines changed: 93 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)