Skip to content

Commit

Permalink
Implementation of RFC 8414 (#57)
Browse files Browse the repository at this point in the history
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`.
  • Loading branch information
oxisto authored Mar 14, 2023
1 parent b2f5548 commit 87f4554
Show file tree
Hide file tree
Showing 10 changed files with 343 additions and 128 deletions.
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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")),
)

Expand All @@ -46,15 +46,16 @@ 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

* [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
* [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
4 changes: 3 additions & 1 deletion cmd/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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("*"),
)
Expand Down
4 changes: 2 additions & 2 deletions example_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
33 changes: 33 additions & 0 deletions jwks.go
Original file line number Diff line number Diff line change
@@ -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"`
Expand All @@ -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)
}
95 changes: 95 additions & 0 deletions jwks_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
})
}
}
10 changes: 5 additions & 5 deletions login/login_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand Down
41 changes: 41 additions & 0 deletions metadata.go
Original file line number Diff line number Diff line change
@@ -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)
}
107 changes: 107 additions & 0 deletions metadata_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
})
}
}
Loading

0 comments on commit 87f4554

Please sign in to comment.