Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 27 additions & 2 deletions client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
package client

import (
"encoding/json"
"strconv"
"strings"
"time"
Expand All @@ -21,8 +22,9 @@ import (
)

var (
_ fosite.OpenIDConnectClient = (*Client)(nil)
_ fosite.Client = (*Client)(nil)
_ fosite.OpenIDConnectClient = (*Client)(nil)
_ fosite.Client = (*Client)(nil)
_ fosite.ClientWithSecretRotation = (*Client)(nil)
)

// OAuth 2.0 Client
Expand Down Expand Up @@ -51,6 +53,12 @@ type Client struct {
// never again. The secret is kept in hashed format and is not recoverable once lost.
Secret string `json:"client_secret,omitempty" db:"client_secret"`

// OAuth 2.0 Client Rotated Secrets
//
// RotatedSecrets holds previously rotated secrets that are still valid for authentication.
// This allows for secret rotation without downtime. Secrets are stored in hashed format.
RotatedSecrets string `json:"rotated_secrets,omitempty" db:"rotated_secrets"`

// OAuth 2.0 Client Redirect URIs
//
// RedirectURIs is an array of allowed redirect urls for the client.
Expand Down Expand Up @@ -432,6 +440,23 @@ func (c *Client) GetHashedSecret() []byte {
return []byte(c.Secret)
}

func (c *Client) GetRotatedHashes() [][]byte {
if c.RotatedSecrets == "" {
return nil
}

var rotated []string
if err := json.Unmarshal([]byte(c.RotatedSecrets), &rotated); err != nil {
return nil
}

hashes := make([][]byte, len(rotated))
for i, secret := range rotated {
hashes[i] = []byte(secret)
}
return hashes
}

func (c *Client) GetScopes() fosite.Arguments {
return strings.Fields(c.Scope)
}
Expand Down
45 changes: 45 additions & 0 deletions client/client_secret_rotation_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// Copyright © 2022 Ory Corp
// SPDX-License-Identifier: Apache-2.0

package client

import (
"encoding/json"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestClient_GetRotatedHashes(t *testing.T) {
t.Run("returns nil when no rotated secrets", func(t *testing.T) {
c := &Client{
RotatedSecrets: "",
}
hashes := c.GetRotatedHashes()
assert.Nil(t, hashes)
})

t.Run("returns hashes when rotated secrets exist", func(t *testing.T) {
secrets := []string{"hash1", "hash2", "hash3"}
secretsJSON, err := json.Marshal(secrets)
require.NoError(t, err)

c := &Client{
RotatedSecrets: string(secretsJSON),
}
hashes := c.GetRotatedHashes()
require.Len(t, hashes, 3)
assert.Equal(t, []byte("hash1"), hashes[0])
assert.Equal(t, []byte("hash2"), hashes[1])
assert.Equal(t, []byte("hash3"), hashes[2])
})

t.Run("returns nil on invalid JSON", func(t *testing.T) {
c := &Client{
RotatedSecrets: "invalid json",
}
hashes := c.GetRotatedHashes()
assert.Nil(t, hashes)
})
}
124 changes: 124 additions & 0 deletions client/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ func (h *Handler) SetAdminRoutes(r *httprouterx.RouterAdmin) {
r.PATCH(ClientsHandlerPath+"/{id}", h.patchOAuth2Client)
r.DELETE(ClientsHandlerPath+"/{id}", h.deleteOAuth2Client)
r.PUT(ClientsHandlerPath+"/{id}/lifespans", h.setOAuth2ClientLifespans)
r.POST(ClientsHandlerPath+"/{id}/secret/rotate", h.rotateOAuth2ClientSecret)
r.DELETE(ClientsHandlerPath+"/{id}/secret/rotate", h.deleteRotatedOAuth2ClientSecrets)
}

func (h *Handler) SetPublicRoutes(r *httprouterx.RouterPublic) {
Expand Down Expand Up @@ -850,3 +852,125 @@ func (h *Handler) requireDynamicAuth(r *http.Request) *herodot.DefaultError {
}
return nil
}

// Rotate OAuth2 Client Secret Parameters
//
// swagger:parameters rotateOAuth2ClientSecret
//
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
type rotateOAuth2ClientSecret struct {
// OAuth 2.0 Client ID
//
// in: path
// required: true
ID string `json:"id"`
}

// swagger:route POST /admin/clients/{id}/secret/rotate oAuth2 rotateOAuth2ClientSecret
//
// # Rotate OAuth 2.0 Client Secret
//
// Rotates the client secret for an OAuth 2.0 client. The old secret will be moved to the rotated_secrets
// array and will remain valid for authentication, allowing for zero-downtime secret rotation.
// A new secret will be generated and returned in the response.
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Responses:
// 200: oAuth2Client
// 404: errorOAuth2NotFound
// default: errorOAuth2Default
func (h *Handler) rotateOAuth2ClientSecret(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
c, err := h.r.ClientManager().GetConcreteClient(r.Context(), id)
if err != nil {
h.r.Writer().WriteError(w, r, err)
return
}

// Move current secret to rotated secrets
oldSecret := string(c.GetHashedSecret())
if oldSecret != "" {
var rotated []string
if c.RotatedSecrets != "" {
if err := json.Unmarshal([]byte(c.RotatedSecrets), &rotated); err != nil {
rotated = []string{}
}
}
rotated = append(rotated, oldSecret)
rotatedJSON, err := json.Marshal(rotated)
if err != nil {
h.r.Writer().WriteError(w, r, err)
return
}
c.RotatedSecrets = string(rotatedJSON)
}

secretb, err := x.GenerateSecret(26)
if err != nil {
h.r.Writer().WriteError(w, r, err)
return
}
newSecret := string(secretb)
c.Secret = newSecret

if err := h.updateClient(r.Context(), c, h.r.ClientValidator().Validate); err != nil {
h.r.Writer().WriteError(w, r, err)
return
}

c.Secret = newSecret
h.r.Writer().Write(w, r, c)
}

// Delete Rotated OAuth2 Client Secrets Parameters
//
// swagger:parameters deleteRotatedOAuth2ClientSecrets
//
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
type deleteRotatedOAuth2ClientSecrets struct {
// OAuth 2.0 Client ID
//
// in: path
// required: true
ID string `json:"id"`
}

// swagger:route DELETE /admin/clients/{id}/secret/rotated oAuth2 deleteRotatedOAuth2ClientSecrets
//
// # Delete Rotated OAuth 2.0 Client Secrets
//
// Removes all rotated secrets from an OAuth 2.0 client. This should be called after all services
// have been updated to use the new secret and the old secrets are no longer needed.
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Responses:
// 200: oAuth2Client
// 404: errorOAuth2NotFound
// default: errorOAuth2Default
func (h *Handler) deleteRotatedOAuth2ClientSecrets(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
c, err := h.r.ClientManager().GetConcreteClient(r.Context(), id)
if err != nil {
h.r.Writer().WriteError(w, r, err)
return
}

c.RotatedSecrets = "[]"
c.Secret = ""

if err := h.updateClient(r.Context(), c, h.r.ClientValidator().Validate); err != nil {
h.r.Writer().WriteError(w, r, err)
return
}

c.Secret = ""
h.r.Writer().Write(w, r, c)
}
2 changes: 1 addition & 1 deletion client/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -554,7 +554,7 @@ func TestHandler(t *testing.T) {
payload, _ := sjson.Set(expected, "redirect_uris", []string{"http://localhost:3000/cb", "https://foobar.com"})
body, res := makeJSON(t, adminTs, "PUT", urlx.MustJoin(client.ClientsHandlerPath, url.PathEscape(expectedID)), json.RawMessage(payload))
assert.Equal(t, http.StatusOK, res.StatusCode)
snapshotx.SnapshotT(t, newResponseSnapshot(body, res), snapshotx.ExceptPaths("body.created_at", "body.updated_at", "body.client_id", "body.registration_client_uri", "body.registration_access_token"))
snapshotx.SnapshotT(t, newResponseSnapshot(body, res), snapshotx.ExceptPaths("body.created_at", "body.updated_at", "body.client_id", "body.registration_client_uri", "body.registration_access_token", "body.rotated_secrets"))
})

t.Run("endpoint=dynamic client registration", func(t *testing.T) {
Expand Down
Loading
Loading