Skip to content

Commit

Permalink
feat(auth): OIDC authentication refactoring
Browse files Browse the repository at this point in the history
- Support opaque tokens
- Get username from UserInfo endpoint

closes #71, closes #69
  • Loading branch information
ncarlier committed Feb 28, 2024
1 parent 8215975 commit 7f53fa0
Show file tree
Hide file tree
Showing 13 changed files with 315 additions and 138 deletions.
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,17 +54,17 @@ docker compose up
## Configuration

Readflow configuration is a TOML file that you can specify using the `--config` command line parameter or by setting the `READFLOW_CONFIG` environment variable.
Readflow configuration is a TOML file that you can specify using the `-c` command line parameter or by setting the `READFLOW_CONFIG` environment variable.

You can initialize a configuration file example by using the `--init-config` command line parameter.
You can initialize a configuration file example by using the `init-config -f config.toml` command.

A configuration file example can be found [here](./pkg/config/defaults.toml).

Type `readflow -h` to display all parameters and related environment variables.
Type `readflow --help` to display available commands.

## UI

You can access Web UI on http://localhost:8080/ui
You can access Web UI on http://localhost:8080

![Screenshot](screenshot.png)

Expand Down
10 changes: 7 additions & 3 deletions pkg/config/defaults.toml
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ hostname = "${READFLOW_SMTP_HOSTNAME}"
# - `mock`: Mocked, aka fake user for testing
# - `basic`: Basic Authentication using htpasswd file
# - `proxy`: Proxied authentication using specific header as username
# - `oidc`: OpenID Connect authentification using JWT access token validation
# - `oidc`: OpenID Connect authentification
# Default: "mock"
method = "${READFLOW_AUTHN_METHOD}"
## Administrators usernames
Expand All @@ -65,6 +65,10 @@ htpasswd_file = "${READFLOW_AUTHN_BASIC_HTPASSWD_FILE}"
## OpenID Connect issuer
# Default: "https://accounts.readflow.app"
issuer = "${READFLOW_AUTHN_OIDC_ISSUER}"
## OpenID client ID (used only for opaque token introspection)
client_id = "${READFLOW_AUTHN_OIDC_CLIENT_ID}"
## OpenID client secret (used only for opaque token introspection)
client_secret = "${READFLOW_AUTHN_OIDC_CLIENT_SECRET}"
[authn.proxy]
## Proxy headers
# Comma separated list of header carrying the username
Expand All @@ -77,9 +81,9 @@ headers = "${READFLOW_AUTHN_PROXY_HEADERS}"
# Example: "/var/local/html"
directory = "${READFLOW_UI_DIRECTORY}"
## UI public URL
# Default: "https://localhost:8080/ui"
# Default: "https://localhost:8080"
public_url = "${READFLOW_UI_PUBLIC_URL}"
## UI client ID (when using OpenID Connect issuer)
## UI client ID (when using OpenID Connect authentication method)
client_id = "${READFLOW_UI_CLIENT_ID}"

[hash]
Expand Down
4 changes: 3 additions & 1 deletion pkg/config/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,9 @@ type AuthNConfig struct {

// AuthNOIDCConfig for OpenID Connect authentication configuration section
type AuthNOIDCConfig struct {
Issuer string `toml:"issuer"`
Issuer string `toml:"issuer"`
ClientID string `toml:"client_id"`
ClientSecret string `toml:"client_secret"`
}

// AuthNProxyConfig for proxy authentication configuration section
Expand Down
2 changes: 1 addition & 1 deletion pkg/config/ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const __READFLOW_CONFIG__ = {
// Values: URL if using OIDC (ex: `https://accounts.readflow.app`), `none` otherwise
// Default: `none`
// Default can be overridden by setting ${REACT_APP_AUTHORITY} env variable during build time
authority: '{{ if eq .AuthN.Method "oidc" }} {{ .AuthN.OIDC.Issuer }} {{ else }} "none" {{ end }}',
authority: '{{ if eq .AuthN.Method "oidc" }}{{ .AuthN.OIDC.Issuer }}{{ else }}none{{ end }}',
// OpenID Connect client ID, default if empty
// Values: string (ex: `[email protected]`)
// Default: ''
Expand Down
2 changes: 1 addition & 1 deletion pkg/middleware/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ func Auth(cfg config.AuthNConfig) Middleware {
authn = BasicAuth(cfg)
case "oidc":
logger = logger.With().Str("issuer", cfg.OIDC.Issuer).Logger()
authn = OpenIDConnectJWTAuth(cfg)
authn = OpenIDConnectAuth(cfg)
default:
log.Fatal().Str("method", cfg.Method).Msg("non supported authentication method")
}
Expand Down
140 changes: 140 additions & 0 deletions pkg/middleware/oidc-auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
package middleware

import (
"context"
"errors"
"net/http"
"strings"

"github.com/golang-jwt/jwt/v4"
jwtRequest "github.com/golang-jwt/jwt/v4/request"
"github.com/ncarlier/readflow/pkg/config"
"github.com/ncarlier/readflow/pkg/constant"
"github.com/ncarlier/readflow/pkg/helper"
"github.com/ncarlier/readflow/pkg/oidc"
"github.com/ncarlier/readflow/pkg/service"
"github.com/rs/zerolog/log"
)

// OpenIDConnectAuth is a middleware to checks HTTP request with a valid OIDC token
func OpenIDConnectAuth(cfg config.AuthNConfig) Middleware {
bearerExtractor := &jwtRequest.BearerExtractor{}
admins := strings.Split(cfg.Admins, ",")
oidcClient, err := oidc.NewOIDCClient(cfg.OIDC.Issuer, cfg.OIDC.ClientID, cfg.OIDC.ClientSecret)
if err != nil {
log.Fatal().Err(err).Str("issuer", cfg.OIDC.Issuer).Msg("unable to create OIDC client form provided issuer")
}
keyFunc := buildKeyFunc(oidcClient)
return func(inner http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()

w.Header().Set("WWW-Authenticate", `Bearer realm="readflow"`)
tokenString, err := bearerExtractor.ExtractToken(r)
if err != nil {
jsonErrors(w, err.Error(), 400)
return
}

username := ""
claims := jwt.MapClaims{}
token, err := jwt.ParseWithClaims(tokenString, claims, keyFunc)
switch {
case err != nil && errors.Is(err, jwt.ErrTokenMalformed):
// asume that the token is an opaque token
// validate token using introspection endpoint and try to get username from it
username, err = getUsernameFormOpaqueToken(tokenString, oidcClient)
case token != nil && token.Valid:
// try to get username from JWT
username, err = getUsernameFromJWT(token)
default:
// error or token invalid
if err == nil {
err = errors.New("Unauthorized")
}
}

if err != nil {
jsonErrors(w, err.Error(), 401)
return
}

if username == "" {
// call UserInfo endpoint to retrive username
// TODO use cache with subject
username, err = getUsernameFromUserInfo(tokenString, oidcClient)
if err != nil {
jsonErrors(w, err.Error(), 403)
return
}
}

if username == "" {
jsonErrors(w, "unable to retrieve username from OIDC endpoints", 403)
return
}

user, err := service.Lookup().GetOrRegisterUser(ctx, username)
if err != nil {
jsonErrors(w, err.Error(), http.StatusInternalServerError)
return
}
ctx = context.WithValue(ctx, constant.ContextUser, *user)
ctx = context.WithValue(ctx, constant.ContextUserID, *user.ID)
ctx = context.WithValue(ctx, constant.ContextIsAdmin, helper.ContainsString(admins, username))
inner.ServeHTTP(w, r.WithContext(ctx))
})
}
}

func buildKeyFunc(client *oidc.Client) jwt.Keyfunc {
return func(token *jwt.Token) (i interface{}, e error) {
if id, ok := token.Header["kid"]; ok {
return client.Keystore.GetKey(id.(string))
}
return nil, errors.New("kid header not found in token")
}
}

func getUsernameFormOpaqueToken(token string, oidcClient *oidc.Client) (username string, err error) {
introspection, err := oidcClient.Introspect(token)
if err != nil {
return
}
if !introspection.Active {
err = errors.New("token is inactive")
return
}
if introspection.PreferredUsername != "" {
username = introspection.PreferredUsername
} else {
username = introspection.Username
}
return
}

func getUsernameFromJWT(token *jwt.Token) (username string, err error) {
if claims, ok := token.Claims.(jwt.MapClaims); ok {
if val, ok := claims["preferred_username"]; ok {
username = val.(string)
} else if val, ok := claims["email"]; ok {
username = val.(string)
}
}
return
}

func getUsernameFromUserInfo(token string, oidcClient *oidc.Client) (username string, err error) {
info, err := oidcClient.UserInfo(token)
if err != nil {
return
}

if info.PreferredUsername != "" {
username = info.PreferredUsername
} else {
username = info.Email
}

return
}
71 changes: 0 additions & 71 deletions pkg/middleware/oidc-jwt-auth.go

This file was deleted.

Loading

0 comments on commit 7f53fa0

Please sign in to comment.