From 87f4554f353544b993118acc0b8e20a2f2583968 Mon Sep 17 00:00:00 2001 From: Christian Banse Date: Tue, 14 Mar 2023 14:20:56 +0100 Subject: [PATCH] Implementation of RFC 8414 (#57) This is an (initial) implementation of RFC 8414 and provides server metadata under the `/.well-known/openid-configuration` URL. This also moves the JWKS URL to `/certs`. --- README.md | 9 ++-- cmd/server/server.go | 4 +- example_test.go | 4 +- jwks.go | 33 +++++++++++++ jwks_test.go | 95 +++++++++++++++++++++++++++++++++++++ login/login_test.go | 10 ++-- metadata.go | 41 ++++++++++++++++ metadata_test.go | 107 ++++++++++++++++++++++++++++++++++++++++++ server.go | 60 +++++++++++------------- server_test.go | 108 ++++++++++--------------------------------- 10 files changed, 343 insertions(+), 128 deletions(-) create mode 100644 jwks_test.go create mode 100644 metadata.go create mode 100644 metadata_test.go diff --git a/README.md b/README.md index fcdf584..704c294 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ import ( func main() { var srv *oauth2.AuthorizationServer - srv = oauth2.NewServer(":8080", + srv = oauth2.NewServer(":8000", login.WithLoginPage(login.WithUser("admin", "admin")), ) @@ -46,10 +46,10 @@ func main() { If you want to use this project as a small standalone authentication server, you can use the Docker image to spawn one. The created user and client credentials will be printed on the console. ``` -docker run -p 8080:8080 ghcr.io/oxisto/oauth2go +docker run -p 8000:8000 ghcr.io/oxisto/oauth2go ``` -A login form is available on http://localhost:8008/login. +A login form is available on http://localhost:8000/login. ## (To be) Implemented Standards @@ -57,4 +57,5 @@ A login form is available on http://localhost:8008/login. * [RFC 6749](https://datatracker.ietf.org/doc/html/rfc6749). The OAuth 2.0 Authorization Framework * [RFC 6750](https://datatracker.ietf.org/doc/html/rfc6750). The OAuth 2.0 Authorization Framework: Bearer Token Usage * [RFC 7517](https://datatracker.ietf.org/doc/html/rfc7517). JSON Web Key (JWK) -* [RFC 7636](https://datatracker.ietf.org/doc/html/rfc7636). Proof Key for Code Exchange by OAuth Public Clients \ No newline at end of file +* [RFC 7636](https://datatracker.ietf.org/doc/html/rfc7636). Proof Key for Code Exchange by OAuth Public Clients +* [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414). OAuth 2.0 Authorization Server Metadata \ No newline at end of file diff --git a/cmd/server/server.go b/cmd/server/server.go index 9a54da5..63d4401 100644 --- a/cmd/server/server.go +++ b/cmd/server/server.go @@ -11,7 +11,8 @@ import ( "github.com/oxisto/oauth2go/login" ) -var port = flag.Int("port", 8080, "the default port") +var port = flag.Int("port", 8000, "the default port") +var publicURL = flag.String("public-url", "http://localhost:8000", "the default public facing URL. Will be used in server metadata") var redirectURI = flag.String("redirect-uri", "http://localhost", "the default redirect URI") var clientSecret = flag.String("client-secret", "", "a client secret. If not specified, one will be generated") @@ -39,6 +40,7 @@ func main() { fmt.Sprintf(":%d", *port), oauth2.WithClient("client", *clientSecret, *redirectURI), oauth2.WithClient("public", "", *redirectURI), + oauth2.WithPublicURL(*publicURL), login.WithLoginPage(login.WithUser("admin", *userPassword)), oauth2.WithAllowedOrigins("*"), ) diff --git a/example_test.go b/example_test.go index 608d41b..edc5539 100644 --- a/example_test.go +++ b/example_test.go @@ -11,14 +11,14 @@ import ( // login page (acting as an authentication server). func ExampleAuthorizationServer() { var srv *oauth2.AuthorizationServer - var port = 8080 + var port = 8000 srv = oauth2.NewServer(fmt.Sprintf(":%d", port), login.WithLoginPage(login.WithUser("admin", "admin")), ) fmt.Printf("Creating new OAuth 2.0 server on %d", port) - // Output: Creating new OAuth 2.0 server on 8080 + // Output: Creating new OAuth 2.0 server on 8000 go srv.ListenAndServe() defer srv.Close() diff --git a/jwks.go b/jwks.go index 6ff4f1b..490e205 100644 --- a/jwks.go +++ b/jwks.go @@ -1,5 +1,11 @@ package oauth2 +import ( + "encoding/base64" + "fmt" + "net/http" +) + // JSONWebKeySet is a JSON Web Key Set. type JSONWebKeySet struct { Keys []JSONWebKey `json:"keys"` @@ -17,3 +23,30 @@ type JSONWebKey struct { Y string `json:"y"` } + +func (srv *AuthorizationServer) handleJWKS(w http.ResponseWriter, r *http.Request) { + var ( + keySet *JSONWebKeySet + ) + + if r.Method != "GET" { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + keySet = &JSONWebKeySet{Keys: []JSONWebKey{}} + + for kid, key := range srv.PublicKeys() { + keySet.Keys = append(keySet.Keys, + JSONWebKey{ + // Currently, our kid is simply a 0-based index value of our signing keys array + Kid: fmt.Sprintf("%d", kid), + Crv: key.Params().Name, + Kty: "EC", + X: base64.RawURLEncoding.EncodeToString(key.X.Bytes()), + Y: base64.RawURLEncoding.EncodeToString(key.Y.Bytes()), + }) + } + + writeJSON(w, keySet) +} diff --git a/jwks_test.go b/jwks_test.go new file mode 100644 index 0000000..f8e9144 --- /dev/null +++ b/jwks_test.go @@ -0,0 +1,95 @@ +package oauth2 + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "encoding/json" + "math/big" + "net/http" + "net/http/httptest" + "reflect" + "testing" +) + +func TestAuthorizationServer_handleJWKS(t *testing.T) { + type fields struct { + clients []*Client + signingKeys map[int]*ecdsa.PrivateKey + } + type args struct { + r *http.Request + } + tests := []struct { + name string + fields fields + args args + want *JSONWebKeySet + wantCode int + }{ + { + name: "retrieve JWKS with GET", + fields: fields{ + signingKeys: map[int]*ecdsa.PrivateKey{ + 0: { + PublicKey: ecdsa.PublicKey{ + Curve: elliptic.P256(), + X: big.NewInt(1), + Y: big.NewInt(2), + }, + }, + }, + }, + args: args{ + r: httptest.NewRequest("GET", "/certs", nil), + }, + want: &JSONWebKeySet{ + Keys: []JSONWebKey{{ + Kid: "0", + Kty: "EC", + Crv: "P-256", + X: "AQ", + Y: "Ag", + }}, + }, + wantCode: http.StatusOK, + }, + { + name: "retrieve JWKS with POST", + fields: fields{}, + args: args{ + r: httptest.NewRequest("POST", "/certs", nil), + }, + want: nil, + wantCode: http.StatusMethodNotAllowed, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + srv := &AuthorizationServer{ + clients: tt.fields.clients, + signingKeys: tt.fields.signingKeys, + } + + rr := httptest.NewRecorder() + srv.handleJWKS(rr, tt.args.r) + + gotCode := rr.Code + if gotCode != tt.wantCode { + t.Errorf("AuthorizationServer.handleJWKS() code = %v, wantCode %v", gotCode, tt.wantCode) + } + + if rr.Code == http.StatusOK { + var got JSONWebKeySet + err := json.Unmarshal(rr.Body.Bytes(), &got) + if err != nil { + panic(err) + } + + if !reflect.DeepEqual(&got, tt.want) { + t.Errorf("AuthorizationServer.handleJWKS() = %v, want %v", got, tt.want) + } + } + }) + } +} diff --git a/login/login_test.go b/login/login_test.go index cb3068d..bbeff84 100644 --- a/login/login_test.go +++ b/login/login_test.go @@ -49,7 +49,7 @@ func Test_handler_doLoginGet(t *testing.T) { }, args: args{ r: &http.Request{ - URL: &url.URL{Host: "localhost:8080", Path: "/login", RawQuery: "failed"}, + URL: &url.URL{Host: "localhost:8000", Path: "/login", RawQuery: "failed"}, }, }, wantCode: http.StatusOK, @@ -64,7 +64,7 @@ func Test_handler_doLoginGet(t *testing.T) { }, args: args{ r: &http.Request{ - URL: &url.URL{Host: "localhost:8080"}, + URL: &url.URL{Host: "localhost:8000"}, }, }, wantCode: http.StatusOK, @@ -82,7 +82,7 @@ func Test_handler_doLoginGet(t *testing.T) { Header: http.Header{ "Cookie": []string{"id=mySession"}, }, - URL: &url.URL{Host: "localhost:8080"}, + URL: &url.URL{Host: "localhost:8000"}, }, }, wantCode: http.StatusOK, @@ -108,7 +108,7 @@ func Test_handler_doLoginGet(t *testing.T) { Header: http.Header{ "Cookie": []string{"id=mySession"}, }, - URL: &url.URL{Host: "localhost:8080"}, + URL: &url.URL{Host: "localhost:8000"}, }, }, wantCode: http.StatusOK, @@ -134,7 +134,7 @@ func Test_handler_doLoginGet(t *testing.T) { Header: http.Header{ "Cookie": []string{"id=mySession"}, }, - URL: &url.URL{Host: "localhost:8080"}, + URL: &url.URL{Host: "localhost:8000"}, }, }, wantCode: http.StatusFound, diff --git a/metadata.go b/metadata.go new file mode 100644 index 0000000..1bad0cd --- /dev/null +++ b/metadata.go @@ -0,0 +1,41 @@ +package oauth2 + +import ( + "net/http" +) + +// ServerMetadata is a struct that contains metadata according to RFC 8414. +// +// See https://datatracker.ietf.org/doc/rfc8414/. +type ServerMetadata struct { + Issuer string `json:"issuer"` + AuthorizationEndpoint string `json:"authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` + JWKSURI string `json:"jwks_uri"` + SupportedScopes []string `json:"scopes_supported"` + SupportedResponseTypes []string `json:"response_types_supported"` + SupportedGrantTypes []string `json:"grant_types_supported"` +} + +// buildMetadata builds a [ServerMetadata] based on the capabilities of this +// server and the public URL. +func buildMetadata(url string) *ServerMetadata { + return &ServerMetadata{ + Issuer: url, + AuthorizationEndpoint: url + "/authorize", + TokenEndpoint: url + "/token", + JWKSURI: url + "/certs", + SupportedScopes: []string{"profile"}, + SupportedResponseTypes: []string{"code"}, + SupportedGrantTypes: []string{"authorization_code", "client_credentials", "refresh_token"}, + } +} + +func (srv *AuthorizationServer) handleMetadata(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + writeJSON(w, srv.metadata) +} diff --git a/metadata_test.go b/metadata_test.go new file mode 100644 index 0000000..6748f40 --- /dev/null +++ b/metadata_test.go @@ -0,0 +1,107 @@ +package oauth2 + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "reflect" + "testing" +) + +func Test_buildMetadata(t *testing.T) { + type args struct { + url string + } + tests := []struct { + name string + args args + want *ServerMetadata + }{ + { + name: "Happy path", + args: args{ + url: "http://localhost:8000", + }, + want: &ServerMetadata{ + Issuer: "http://localhost:8000", + AuthorizationEndpoint: "http://localhost:8000/authorize", + TokenEndpoint: "http://localhost:8000/token", + JWKSURI: "http://localhost:8000/certs", + SupportedScopes: []string{"profile"}, + SupportedResponseTypes: []string{"code"}, + SupportedGrantTypes: []string{"authorization_code", "client_credentials", "refresh_token"}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := buildMetadata(tt.args.url); !reflect.DeepEqual(got, tt.want) { + t.Errorf("buildMetadata() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestAuthorizationServer_handleMetadata(t *testing.T) { + type fields struct { + metadata *ServerMetadata + } + type args struct { + r *http.Request + } + tests := []struct { + name string + fields fields + args args + want *ServerMetadata + wantCode int + }{ + { + name: "wrong method", + fields: fields{}, + args: args{ + r: httptest.NewRequest("POST", "/.well-known/openid-configuration", nil), + }, + want: nil, + wantCode: http.StatusMethodNotAllowed, + }, + { + name: "valid metadata", + fields: fields{ + metadata: buildMetadata(DefaultAddress), + }, + args: args{ + r: httptest.NewRequest("GET", "/.well-known/openid-configuration", nil), + }, + want: buildMetadata(DefaultAddress), + wantCode: 200, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + srv := &AuthorizationServer{ + metadata: tt.fields.metadata, + } + + rr := httptest.NewRecorder() + srv.handleMetadata(rr, tt.args.r) + + gotCode := rr.Code + if gotCode != tt.wantCode { + t.Errorf("AuthorizationServer.handleMetadata() code = %v, wantCode %v", gotCode, tt.wantCode) + } + + if rr.Code == http.StatusOK { + var got ServerMetadata + err := json.Unmarshal(rr.Body.Bytes(), &got) + if err != nil { + panic(err) + } + + if !reflect.DeepEqual(&got, tt.want) { + t.Errorf("AuthorizationServer.handleMetadata() = %v, want %v", got, tt.want) + } + } + }) + } +} diff --git a/server.go b/server.go index 7afa8e5..7c2e377 100644 --- a/server.go +++ b/server.go @@ -30,6 +30,7 @@ const ( ErrorInvalidGrant = "invalid_grant" DefaultExpireIn = time.Hour * 24 + DefaultAddress = "http://localhost:8000" ) type codeInfo struct { @@ -41,17 +42,25 @@ type codeInfo struct { type AuthorizationServer struct { http.Server - // our clients + // clients contains our clients clients []*Client - // our signing keys + // signingKeys contains our signing keys signingKeys map[int]*ecdsa.PrivateKey - // our codes and their expiry time and challenge + // codes contains our codes and their expiry time and challenge codes map[string]*codeInfo - // the allowed CORS origin + // allowedOrigin is the allowed CORS origin allowedOrigin string + + // publicURL is the public facing address of this server. This is used to + // populate its metadata. + publicURL string + + // metadata contains server metadata according to RFC 8414. This is + // populated automatically. + metadata *ServerMetadata } type AuthorizationServerOption func(srv *AuthorizationServer) @@ -77,6 +86,12 @@ func WithClient( } } +func WithPublicURL(publicURL string) AuthorizationServerOption { + return func(srv *AuthorizationServer) { + srv.publicURL = publicURL + } +} + func WithSigningKeysFunc(f signingKeysFunc) AuthorizationServerOption { return func(srv *AuthorizationServer) { srv.signingKeys = f() @@ -105,12 +120,20 @@ func NewServer(addr string, opts ...AuthorizationServerOption) *AuthorizationSer o(srv) } + // Build metadata + if srv.publicURL == "" { + srv.publicURL = DefaultAddress + } + srv.metadata = buildMetadata(srv.publicURL) + if srv.signingKeys == nil { srv.signingKeys = generateSigningKeys() } mux.HandleFunc("/token", srv.handleToken) - mux.HandleFunc("/.well-known/jwks.json", srv.handleJWKS) + mux.HandleFunc("/certs", srv.handleJWKS) + mux.HandleFunc("/.well-known/oauth-authorization-server", srv.handleMetadata) + mux.HandleFunc("/.well-known/openid-configuration", srv.handleMetadata) return srv } @@ -284,33 +307,6 @@ issue: writeToken(w, token) } -func (srv *AuthorizationServer) handleJWKS(w http.ResponseWriter, r *http.Request) { - var ( - keySet *JSONWebKeySet - ) - - if r.Method != "GET" { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - - keySet = &JSONWebKeySet{Keys: []JSONWebKey{}} - - for kid, key := range srv.PublicKeys() { - keySet.Keys = append(keySet.Keys, - JSONWebKey{ - // Currently, our kid is simply a 0-based index value of our signing keys array - Kid: fmt.Sprintf("%d", kid), - Crv: key.Params().Name, - Kty: "EC", - X: base64.RawURLEncoding.EncodeToString(key.X.Bytes()), - Y: base64.RawURLEncoding.EncodeToString(key.Y.Bytes()), - }) - } - - writeJSON(w, keySet) -} - // GetClient returns the client for the given ID or ErrClientNotFound. func (srv *AuthorizationServer) GetClient(clientID string) (*Client, error) { // Look for a matching client diff --git a/server_test.go b/server_test.go index 5191c44..a78709c 100644 --- a/server_test.go +++ b/server_test.go @@ -4,7 +4,6 @@ import ( "crypto/ecdsa" "crypto/elliptic" "crypto/rand" - "encoding/json" "errors" "fmt" "io" @@ -216,89 +215,6 @@ func TestAuthorizationServer_retrieveClient(t *testing.T) { } } -func TestAuthorizationServer_handleJWKS(t *testing.T) { - type fields struct { - clients []*Client - signingKeys map[int]*ecdsa.PrivateKey - } - type args struct { - r *http.Request - } - tests := []struct { - name string - fields fields - args args - want *JSONWebKeySet - wantCode int - }{ - { - name: "retrieve JWKS with GET", - fields: fields{ - signingKeys: map[int]*ecdsa.PrivateKey{ - 0: { - PublicKey: ecdsa.PublicKey{ - Curve: elliptic.P256(), - X: big.NewInt(1), - Y: big.NewInt(2), - }, - }, - }, - }, - args: args{ - r: httptest.NewRequest("GET", "/.well-known/jwks.json", nil), - }, - want: &JSONWebKeySet{ - Keys: []JSONWebKey{{ - Kid: "0", - Kty: "EC", - Crv: "P-256", - X: "AQ", - Y: "Ag", - }}, - }, - wantCode: http.StatusOK, - }, - { - name: "retrieve JWKS with POST", - fields: fields{}, - args: args{ - r: httptest.NewRequest("POST", "/.well-known/jwks.json", nil), - }, - want: nil, - wantCode: http.StatusMethodNotAllowed, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - srv := &AuthorizationServer{ - clients: tt.fields.clients, - signingKeys: tt.fields.signingKeys, - } - - rr := httptest.NewRecorder() - srv.handleJWKS(rr, tt.args.r) - - gotCode := rr.Code - if gotCode != tt.wantCode { - t.Errorf("AuthorizationServer.doLoginPost() code = %v, wantCode %v", gotCode, tt.wantCode) - } - - if rr.Code == http.StatusOK { - var got JSONWebKeySet - err := json.Unmarshal(rr.Body.Bytes(), &got) - if err != nil { - panic(err) - } - - if !reflect.DeepEqual(&got, tt.want) { - t.Errorf("AuthorizationServer.handleJWKS() = %v, want %v", got, tt.want) - } - } - }) - } -} - func Test_writeJSON(t *testing.T) { type args struct { w http.ResponseWriter @@ -945,6 +861,30 @@ func TestNewServer(t *testing.T) { signingKeys: map[int]*ecdsa.PrivateKey{ 0: testSigningKey, }, + publicURL: DefaultAddress, + metadata: buildMetadata(DefaultAddress), + }, + }, + { + name: "with public URL", + args: args{ + opts: []AuthorizationServerOption{ + WithPublicURL("http://localhost:8080"), + WithSigningKeysFunc(func() (keys map[int]*ecdsa.PrivateKey) { + return map[int]*ecdsa.PrivateKey{ + 0: testSigningKey, + } + }), + }, + }, + want: &AuthorizationServer{ + clients: []*Client{}, + codes: map[string]*codeInfo{}, + signingKeys: map[int]*ecdsa.PrivateKey{ + 0: testSigningKey, + }, + publicURL: "http://localhost:8080", + metadata: buildMetadata("http://localhost:8080"), }, }, }