Skip to content
Snippets Groups Projects
Commit 05bcdf7c authored by Nicolas Pernoud's avatar Nicolas Pernoud
Browse files

feat: minimal OIDC server

parent 7eb106ce
No related branches found
No related tags found
No related merge requests found
package oidcserver
import (
"net/http"
"net/url"
"strings"
"time"
"forge.grandlyon.com/npernoud/glcpro/pkg/common"
"forge.grandlyon.com/npernoud/glcpro/pkg/tokens"
)
// CreateOIDCServer creates a Open ID Connect Server serve mux for development purposes
func CreateOIDCServer() *http.ServeMux {
mux := http.NewServeMux()
// Returns authorization code back to the user
mux.HandleFunc("/auth", func(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
// Check that the scope contains "profile and openid"
scope := strings.Split(query.Get("scope"), " ")
if !(common.Contains(scope, "profile")) || !(common.Contains(scope, "openid")) {
http.Error(w, "only profile openid scope is supported at the moment", http.StatusNotImplemented)
return
}
/// TODO : check that the client id is an actual client id
// Check that an client id is present
clientID := query.Get("client_id")
if clientID == "" {
http.Error(w, "a client id must be present", http.StatusBadRequest)
return
}
/// TODO : check that redirect_uri is allowed for the client
// Check that the redirect_uri is present and can be parsed as an url
redirectUri := query.Get("redirect_uri")
_, err := url.ParseRequestURI(redirectUri)
if err != nil || redirectUri == "" {
http.Error(w, "the redirect uri is not correct", http.StatusBadRequest)
return
}
// Check that the response type is code
responseType := query.Get("response_type")
if responseType != "code" {
http.Error(w, "only authorisation code flow (response_type=code) is implemented at the moment", http.StatusNotImplemented)
return
}
// Create a code which is an opaque token containing some data which will be transformed into a JWT on call to /token
/// TODO : replace by a proxy to france connect, a screen with the SIRET/SIREN input, a call to API Entreprise and wrap everything (identity and company) into the opaque token
code, _ := tokens.Manager.CreateToken("some very personnal data", time.Now().Add(time.Second*60))
params := url.Values{}
params.Add("state", query.Get("state"))
params.Add("code", code)
http.Redirect(w, r, redirectUri+"?"+params.Encode(), http.StatusFound)
})
// Returns access token back to the user
mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
/// TODO : check that the client id is an actual client id
// Check that an client id is present
clientID := query.Get("client_id")
if clientID == "" {
http.Error(w, "a client id must be present", http.StatusBadRequest)
return
}
/// TODO : check that the client secret is an actual client secret
// Check that an client secret is present
clientSecret := query.Get("client_secret")
if clientSecret == "" {
http.Error(w, "a client secret must be present", http.StatusBadRequest)
return
}
// Check that the grant type is code
responseType := query.Get("grant_type")
if responseType != "authorization_code" {
http.Error(w, "only authorisation code flow (grant_type=authorization_code) is implemented at the moment", http.StatusNotImplemented)
return
}
// Get and decode the autorisation code
/// TODO : tokenData will not stay a string but will be an id with company data
var tokenData string
_, err := tokens.Manager.ExtractAndValidateToken(r, "", &tokenData, false)
if err != nil {
http.Error(w, "authorization code is invalid :"+err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/x-www-form-urlencoded")
w.Write([]byte("id_token=" + tokenData + "&scope=user&token_type=id_token"))
})
// Returns userinfo back to the user
/// TODO : implement
// Logout
/// TODO : Nothing : there is no persistent cookie at the time
return mux
}
package oidcserver
import (
"net/http"
"regexp"
"testing"
"forge.grandlyon.com/npernoud/glcpro/pkg/tester"
)
var (
noH map[string]string
)
func TestOIDCServer(t *testing.T) {
h := CreateOIDCServer()
do := tester.CreateHandlerTester(t, h)
////////////////
// AUTH TESTS //
////////////////
// Test that incorrects scope gives an error
do("GET", "/auth", noH, "", http.StatusNotImplemented, "only profile openid scope is supported at the moment")
do("GET", "/auth?scope=profile otherclaim", noH, "", http.StatusNotImplemented, "only profile openid scope is supported at the moment")
// Test that no client id gives an error
do("GET", "/auth?scope=openid profile", noH, "", http.StatusBadRequest, "a client id must be present")
// Test that an invalid redirect uri gives an error
do("GET", "/auth?scope=openid profile&client_id=A_RANDOM_ID", noH, "", http.StatusBadRequest, "the redirect uri is not correct")
do("GET", "/auth?scope=openid profile&client_id=A_RANDOM_ID&redirect_uri=NOTANURI", noH, "", http.StatusBadRequest, "the redirect uri is not correct")
// Test that an invalid response_type gives an error
do("GET", "/auth?scope=openid profile&client_id=A_RANDOM_ID&redirect_uri=http://www.grandlyon.com", noH, "", http.StatusNotImplemented, "only authorisation code flow")
do("GET", "/auth?scope=openid profile&client_id=A_RANDOM_ID&redirect_uri=http://www.grandlyon.com&response_type=other", noH, "", http.StatusNotImplemented, "only authorisation code flow")
// Test that a correct request gives a redirection (and store the code for later)
bodyWithCode := do("GET", "/auth?scope=openid profile&client_id=A_RANDOM_ID&redirect_uri=http://www.grandlyon.com&response_type=code&state=A_RANDOM_STATE", noH, "", http.StatusFound, "<a href=\"http://www.grandlyon.com")
code := regexp.MustCompile(`.*code=(.*)"`).FindStringSubmatch(bodyWithCode)[1]
/////////////////
// TOKEN TESTS //
/////////////////
// Test that no client id gives an error
do("GET", "/token", noH, "", http.StatusBadRequest, "a client id must be present")
// Test that no client secret gives an error
do("GET", "/token?client_id=A_RANDOM_ID", noH, "", http.StatusBadRequest, "a client secret must be present")
// Test that an invalid response_type gives an error
do("GET", "/token?client_id=A_RANDOM_ID&client_secret=A_RANDOM_SECRET", noH, "", http.StatusNotImplemented, "only authorisation code flow")
do("GET", "/token?client_id=A_RANDOM_ID&client_secret=A_RANDOM_SECRET&grant_type=other", noH, "", http.StatusNotImplemented, "only authorisation code flow")
// Test that no code gives an error
do("GET", "/token?client_id=A_RANDOM_ID&client_secret=A_RANDOM_SECRET&grant_type=authorization_code", noH, "", http.StatusBadRequest, "authorization code is invalid")
// Test that a wrong code gives an error
do("GET", "/token?client_id=A_RANDOM_ID&client_secret=A_RANDOM_SECRET&grant_type=authorization_code&code=NOT_A_CODE", noH, "", http.StatusBadRequest, "authorization code is invalid")
// Test that a correct code give the wanted response
do("GET", "/token?client_id=A_RANDOM_ID&client_secret=A_RANDOM_SECRET&grant_type=authorization_code&code="+code, noH, "", http.StatusOK, "id_token=some very personnal data")
}
...@@ -32,6 +32,14 @@ func DoRequestOnHandler(t *testing.T, router http.Handler, method string, route ...@@ -32,6 +32,14 @@ func DoRequestOnHandler(t *testing.T, router http.Handler, method string, route
return string(rr.Body.String()) return string(rr.Body.String())
} }
// CreateServerTester wraps DoRequestOnHandler to factorize t
func CreateHandlerTester(t *testing.T, h http.Handler) DoFn {
return func(method string, url string, headers map[string]string, payload string, expectedStatus int, expectedBody string) string {
// Create the cookie jar
return DoRequestOnHandler(t, h, method, url, headers, payload, expectedStatus, expectedBody)
}
}
// DoRequestOnServer does a request on listening server // DoRequestOnServer does a request on listening server
func DoRequestOnServer(t *testing.T, port string, jar *cookiejar.Jar, method string, testURL string, headers map[string]string, payload string, expectedStatus int, expectedBody string) string { func DoRequestOnServer(t *testing.T, port string, jar *cookiejar.Jar, method string, testURL string, headers map[string]string, payload string, expectedStatus int, expectedBody string) string {
if strings.HasPrefix(testURL, "/") { if strings.HasPrefix(testURL, "/") {
......
package tokens
import (
"bytes"
"compress/flate"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"io/ioutil"
"net/http"
"os"
"time"
"forge.grandlyon.com/npernoud/glcpro/pkg/common"
"forge.grandlyon.com/npernoud/glcpro/pkg/log"
)
var (
now = time.Now
// Manager is the current token manager
Manager manager
)
// manager manages tokens
type manager struct {
key []byte
debugMode bool
}
// Init inits the main token manager
func init() {
Manager = newManager()
}
// newManager creates a manager
func newManager() manager {
var keyConfig struct {
Key []byte
}
keyFromEnv := os.Getenv("TOKENS_KEY")
if keyFromEnv != "" {
keyConfig.Key = []byte(keyFromEnv)
} else {
var err error
keyConfig.Key, err = common.GenerateRandomBytes(32)
if err != nil {
log.Logger.Fatal(err)
}
}
log.Logger.Printf("Token signing key set : %v\n", string(keyConfig.Key))
return manager{
key: keyConfig.Key,
debugMode: common.BoolValueFromEnv("DEBUG_MODE", false),
}
}
// Token represents a token containting data
type Token struct {
ExpiresAt int64
IssuedAt int64 `json:"iat,omitempty"`
Data []byte
}
// StoreData creates a token with the given data and returns it in a cookie
func (m manager) StoreData(data interface{}, hostName string, cookieName string, duration time.Duration, w http.ResponseWriter) {
expiration := now().Add(duration)
value, err := m.CreateToken(data, expiration)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
cookie := http.Cookie{Name: cookieName, Domain: hostName, Value: value, Expires: expiration, Secure: !m.debugMode, HttpOnly: true, SameSite: http.SameSiteLaxMode}
http.SetCookie(w, &cookie)
}
// CreateToken creates a token with the given data
func (m manager) CreateToken(data interface{}, expiration time.Time) (string, error) {
// Marshall the data
d, err := json.Marshal(data)
if err != nil {
return "", err
}
// Create the payload
token := Token{
ExpiresAt: expiration.Unix(),
Data: d,
}
// Serialize the payload
sToken, err := json.Marshal(token)
if err != nil {
return "", err
}
// Compress with deflate
var csToken bytes.Buffer
c, err := flate.NewWriter(&csToken, flate.BestCompression)
if _, err := c.Write(sToken); err != nil {
return "", err
}
if err := c.Close(); err != nil {
return "", err
}
ecsToken, err := Encrypt(csToken.Bytes(), m.key)
if err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(ecsToken), nil
}
// ExtractAndValidateToken extracts the token from the request, validates it, and return the data n the value pointed to by v
func (m manager) ExtractAndValidateToken(r *http.Request, cookieName string, v interface{}, checkXSRF bool) (bool, error) {
becsToken, checkXSRF, err := func(r *http.Request, checkXSRF bool) (string, bool, error) {
// Try to extract from the query
query := r.URL.Query().Get("code")
if query != "" {
return query, false, nil
}
// Try to extract from the cookie
cookie, err := r.Cookie(cookieName)
if err == nil {
return cookie.Value, checkXSRF, err
}
return "", false, errors.New("could not extract token")
}(r, checkXSRF)
if err == nil {
return checkXSRF, m.unstoreData(becsToken, v)
}
return false, err
}
// unstoreData decrypt, uncompress, unserialize the token, and returns the data n the value pointed to by v
func (m manager) unstoreData(becsToken string, v interface{}) error {
// Decrypt the token
ecsToken, err := base64.StdEncoding.DecodeString(becsToken)
if err != nil {
return fmt.Errorf("failed to unbase64 token")
}
csToken, err := Decrypt(ecsToken, m.key)
if err != nil {
return fmt.Errorf("failed to decrypt token")
}
// Uncompress the token
rdata := bytes.NewReader(csToken)
r := flate.NewReader(rdata)
sToken, err := ioutil.ReadAll(r)
if err != nil {
return fmt.Errorf("failed to uncompress token")
}
// Unserialize the token
token := Token{}
err = json.Unmarshal(sToken, &token)
if err != nil {
return fmt.Errorf("failed to unmarshall token")
}
// Validate the token
if token.ExpiresAt < now().Unix() {
return fmt.Errorf("token expired")
}
// Update the data
err = json.Unmarshal(token.Data, v)
// Return no error if everything is fine
return nil
}
// Encrypt a byte array with AES
func Encrypt(data []byte, key []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return []byte{}, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return []byte{}, err
}
nonce := make([]byte, gcm.NonceSize())
if _, err = io.ReadFull(rand.Reader, nonce); err != nil {
return []byte{}, err
}
cipherData := gcm.Seal(nonce, nonce, data, nil)
return cipherData, nil
}
// Decrypt a byte array with AES
func Decrypt(data []byte, key []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return []byte{}, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return []byte{}, err
}
nonceSize := gcm.NonceSize()
if len(data) <= nonceSize {
return []byte{}, err
}
nonce, cipherData := data[:nonceSize], data[nonceSize:]
plainData, err := gcm.Open(nil, nonce, cipherData, nil)
if err != nil {
return []byte{}, err
}
return plainData, nil
}
package tokens
import (
"fmt"
"testing"
"time"
"forge.grandlyon.com/npernoud/glcpro/pkg/common"
)
type user struct {
Login string
Password string
}
func (u user) String() string {
return fmt.Sprintf("Login: %v, Password: %v", u.Login, u.Password)
}
func TestManagerCreateTokenUnStoreData(t *testing.T) {
key, _ := common.GenerateRandomBytes(32)
key2, _ := common.GenerateRandomBytes(32)
type fields struct {
encryptKey []byte
decryptKey []byte
debugMode bool
}
type args struct {
data interface{}
expiration time.Time
}
tests := []struct {
name string
fields fields
args args
want bool
wantErr bool
}{
{"future_expiration", fields{key, key, false}, args{user{"admin", "password"}, time.Now().Add(24 * time.Hour)}, true, false},
{"past_expiration", fields{key, key, false}, args{user{"admin", "password"}, time.Now().Add(-24 * time.Hour)}, false, true},
{"incorrect_aes_key", fields{[]byte("wrong_key_size"), []byte("wrong_key_size"), false}, args{user{"admin", "password"}, time.Now().Add(+24 * time.Hour)}, false, true},
{"wrong_decrypt_key", fields{key, key2, false}, args{user{"admin", "password"}, time.Now().Add(+24 * time.Hour)}, false, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
m := manager{
key: tt.fields.encryptKey,
debugMode: tt.fields.debugMode,
}
token, _ := m.CreateToken(tt.args.data, tt.args.expiration)
m.key = tt.fields.decryptKey
v := user{}
err := m.unstoreData(token, &v)
got := tt.args.data == v
if (err != nil) != tt.wantErr {
t.Errorf("manager.(un)storeData() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("manager.(un)storeData() inData:%v, outData:%v => equality: %v, want %v", tt.args.data, v, got, tt.want)
}
})
}
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment