Skip to content

Commit

Permalink
API: OIDC token auth (#1375)
Browse files Browse the repository at this point in the history
* add permissions to user me

* add option to authenticate API requests with JWT token

* ui

* fix roles page
  • Loading branch information
BeryJu authored Dec 20, 2024
1 parent 258819c commit b83fd9a
Show file tree
Hide file tree
Showing 13 changed files with 190 additions and 21 deletions.
2 changes: 2 additions & 0 deletions docs/content/docs/api/_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ Session authentication using local users and OIDC is supported for browser usage

To authenticate to the API using a token, create the token either using [ADMIN_TOKEN](../install/_index.md#advanced), or in the Web Interface under __Auth -> Tokens__. Upon creation, the token will be shown in the browser. Afterwards, add the `Authorization` header to API requests with the value of `Bearer <token>`.

Starting with Gravity 0.19, when OIDC is enabled, JWT tokens signed by the OIDC issuer can also be used. The role configuration parameter `tokenUsernameField` configures which claim from the JWT is used to lookup the user.

#### CLI

To create a new user, run the following command in the Gravity container:
Expand Down
1 change: 1 addition & 0 deletions docs/content/docs/api/role_config.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,6 @@ title: "Role configuration"
The placeholder `$INSTANCE_IDENTIFIER` will be replaced by the instance's name and `$INSTANCE_IP` will be replaced by the instances IP.

- `scopes`: Array of scopes that are requested. Should contain `openid` and `email`.
- `tokenUsernameField`: Field used from JWT tokens to find the user when JWT is used for token authentication.

When OpenID Connect is configured, Gravity will automatically start SSO authentication. To prevent this, add the query parameter `local` to the Gravity URL, like `http://gravity1.domain.tld/ui/?local`.
2 changes: 2 additions & 0 deletions hack/tests/dex/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ staticPasswords:
hash: "$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W"
userID: "08a8684b-db88-4b73-90a9-3cd1661f5466"
username: "admin"
oauth2:
passwordConnector: local
storage:
config:
file: /tmp/dex.db
Expand Down
1 change: 0 additions & 1 deletion pkg/roles/api/api_transport_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,6 @@ func TestImport(t *testing.T) {
},
},
}
// var output struct{}

err := role.APIClusterImport().Interact(ctx, entries, &struct{}{})
assert.NoError(t, err)
Expand Down
6 changes: 4 additions & 2 deletions pkg/roles/api/auth/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,9 @@ func (ap *AuthProvider) APIConfig() usecase.Interactor {
}

type APIMeOutput struct {
Username string `json:"username" required:"true"`
Authenticated bool `json:"authenticated" required:"true"`
Username string `json:"username" required:"true"`
Authenticated bool `json:"authenticated" required:"true"`
Permissions []Permission `json:"permissions" required:"true"`
}

func (ap *AuthProvider) APIMe() usecase.Interactor {
Expand All @@ -45,6 +46,7 @@ func (ap *AuthProvider) APIMe() usecase.Interactor {
user := u.(User)
output.Authenticated = true
output.Username = user.Username
output.Permissions = user.Permissions
return nil
})
u.SetName("api.users_me")
Expand Down
2 changes: 1 addition & 1 deletion pkg/roles/api/auth/method_api_key.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import (
"go.uber.org/zap"
)

func (ap *AuthProvider) checkToken(r *http.Request) bool {
func (ap *AuthProvider) checkStaticToken(r *http.Request) bool {
header := r.Header.Get(AuthorizationHeader)
if header == "" {
return false
Expand Down
48 changes: 48 additions & 0 deletions pkg/roles/api/auth/method_oidc.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,3 +106,51 @@ func (ap *AuthProvider) oidcCallback(w http.ResponseWriter, r *http.Request) {
session.Values[types.SessionKeyDirty] = true
http.Redirect(w, r, "/", http.StatusFound)
}

func (ap *AuthProvider) checkJWTToken(r *http.Request) bool {
header := r.Header.Get(AuthorizationHeader)
if header == "" {
return false
}
parts := strings.SplitN(header, " ", 2)
if len(parts) < 2 {
return false
}
if !strings.EqualFold(parts[0], BearerType) {
return false
}
t, err := ap.oidcVerifier.Verify(r.Context(), parts[1])
if err != nil {
ap.log.Warn("failed to verify JWT token", zap.Error(err))
return false
}
rt := map[string]interface{}{}
err = t.Claims(&rt)
if err != nil {
return false
}
// Get token's user
rawUsers, err := ap.inst.KV().Get(
r.Context(),
ap.inst.KV().Key(
types.KeyRole,
types.KeyUsers,
rt[ap.oidc.TokenUsernameField].(string),
).String(),
)
if err != nil {
ap.log.Warn("failed to check token", zap.Error(err))
return false
}
if len(rawUsers.Kvs) < 1 {
return false
}
user, err := ap.userFromKV(rawUsers.Kvs[0])
if err != nil {
return false
}
session := r.Context().Value(types.RequestSession).(*sessions.Session)
session.Values[types.SessionKeyUser] = *user
session.Values[types.SessionKeyDirty] = true
return false
}
90 changes: 90 additions & 0 deletions pkg/roles/api/auth/method_oidc_test.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
package auth_test

import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"

"beryju.io/gravity/pkg/instance"
"beryju.io/gravity/pkg/roles/api"
"beryju.io/gravity/pkg/roles/api/auth"
"beryju.io/gravity/pkg/roles/api/types"
"beryju.io/gravity/pkg/tests"
"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -40,3 +43,90 @@ func TestAuthOIDC(t *testing.T) {
loc, _ := rr.Result().Location()
assert.True(t, strings.HasPrefix(loc.String(), "http://127.0.0.1:5556/dex/auth"), loc.String())
}

func TestAuthOIDC_Token(t *testing.T) {
defer tests.Setup(t)()

// get initial token from Dex
// https://dexidp.io/docs/connectors/local/

data := url.Values{}
data.Set("grant_type", "password")
data.Set("scope", "openid profile email")
data.Set("username", "admin@example.com")
data.Set("password", "password")

req, err := http.NewRequest(http.MethodPost, "http://127.0.0.1:5556/dex/token", strings.NewReader(data.Encode()))
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Basic Z3Jhdml0eTowOGE4Njg0Yi1kYjg4LTRiNzMtOTBhOS0zY2QxNjYxZjU0NjY=")
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")

res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
type b struct {
IDToken string `json:"id_token"`
}
bo := b{}
err = json.NewDecoder(res.Body).Decode(&bo)
if err != nil {
panic(err)
}

rootInst := instance.New()
ctx := tests.Context()
inst := rootInst.ForRole("api", ctx)

tests.PanicIfError(inst.KV().Put(
ctx,
inst.KV().Key(
types.KeyRole,
types.KeyUsers,
"admin@example.com",
).String(),
tests.MustJSON(auth.User{}),
))

role := api.New(inst)
assert.NoError(t, role.Start(ctx, []byte(tests.MustJSON(api.RoleConfig{
ListenOverride: tests.Listen(8008),
OIDC: &types.OIDCConfig{
Issuer: "http://127.0.0.1:5556/dex",
ClientID: "gravity",
ClientSecret: "08a8684b-db88-4b73-90a9-3cd1661f5466",
RedirectURL: "http://localhost:8008/auth/oidc/callback",
Scopes: []string{"openid", "email"},
TokenUsernameField: "email",
},
}))))
defer role.Stop()

// Actual test with the token we just got

rr := httptest.NewRecorder()
req, _ = http.NewRequest(http.MethodGet, "/api/v1/auth/me", nil)
req.Header.Set("Authorization", "Bearer "+bo.IDToken)
role.Mux().ServeHTTP(rr, req)

assert.Equal(t, http.StatusOK, rr.Result().StatusCode)
assert.JSONEq(t, tests.MustJSON(auth.APIMeOutput{
Username: "admin@example.com",
Authenticated: true,
}), rr.Body.String())

// test with invalid token

rr = httptest.NewRecorder()
req, _ = http.NewRequest(http.MethodGet, "/api/v1/auth/me", nil)
req.Header.Set("Authorization", "Bearer "+bo.IDToken+"foo")
role.Mux().ServeHTTP(rr, req)

assert.Equal(t, http.StatusOK, rr.Result().StatusCode)
assert.JSONEq(t, tests.MustJSON(auth.APIMeOutput{
Username: "",
Authenticated: false,
}), rr.Body.String())
}
5 changes: 4 additions & 1 deletion pkg/roles/api/auth/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,13 @@ func (ap *AuthProvider) isAllowedPath(r *http.Request) bool {
}

func (ap *AuthProvider) isRequestAllowed(r *http.Request) bool {
ap.checkStaticToken(r)
if ap.oidc != nil {
ap.checkJWTToken(r)
}
if ap.isAllowedPath(r) {
return true
}
ap.checkToken(r)
session := r.Context().Value(types.RequestSession).(*sessions.Session)
u, ok := session.Values[types.SessionKeyUser]
if u == nil || !ok {
Expand Down
11 changes: 6 additions & 5 deletions pkg/roles/api/types/oidc.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
package types

type OIDCConfig struct {
ClientID string `json:"clientID"`
ClientSecret string `json:"clientSecret"`
Issuer string `json:"issuer"`
RedirectURL string `json:"redirectURL"`
Scopes []string `json:"scopes"`
ClientID string `json:"clientID"`
ClientSecret string `json:"clientSecret"`
Issuer string `json:"issuer"`
RedirectURL string `json:"redirectURL"`
Scopes []string `json:"scopes"`
TokenUsernameField string `json:"tokenUsernameField"`
}
10 changes: 9 additions & 1 deletion schema.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
openapi: 3.0.3
info:
title: gravity
version: 0.18.6
version: 0.18.4
paths:
/api/v1/auth/config:
get:
Expand Down Expand Up @@ -1758,11 +1758,17 @@ components:
properties:
authenticated:
type: boolean
permissions:
items:
$ref: '#/components/schemas/AuthPermission'
nullable: true
type: array
username:
type: string
required:
- username
- authenticated
- permissions
type: object
AuthAPIToken:
properties:
Expand Down Expand Up @@ -2613,4 +2619,6 @@ components:
type: string
nullable: true
type: array
tokenUsernameField:
type: string
type: object
15 changes: 14 additions & 1 deletion web/src/pages/cluster/RoleAPIConfigForm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,14 +55,14 @@ export class RoleAPIConfigForm extends ModelForm<ApiRoleConfig, string> {
label="Session Duration"
?required=${true}
name="sessionDuration"
helperText="Duration for which a session is valid for."
>
<input
type="text"
value="${first(this.instance?.sessionDuration, "24h")}"
class="pf-c-form-control"
required
/>
<p class="pf-c-form__helper-text">Duration for which a session is valid for.</p>
</ak-form-element-horizontal>
<ak-form-group ?expanded=${true}>
<span slot="header">OIDC</span>
Expand Down Expand Up @@ -126,6 +126,19 @@ export class RoleAPIConfigForm extends ModelForm<ApiRoleConfig, string> {
required
/>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label="Token username field"
?required=${true}
name="oidc.tokenUsernameField"
helperText="Field in JWT tokens used to lookup user when using Token API authentication."
>
<input
type="text"
value="${first(this.instance?.oidc?.tokenUsernameField, "email")}"
class="pf-c-form-control"
required
/>
</ak-form-element-horizontal>
</div>
</ak-form-group>`;
}
Expand Down
18 changes: 9 additions & 9 deletions web/src/pages/cluster/RolesPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ export class RolesPage extends TablePage<Role> {
PFGrid,
PFCard,
css`
.pf-c-sidebar__content {
.pf-v6-c-sidebar__content {
background-color: transparent;
}
`,
Expand Down Expand Up @@ -142,16 +142,16 @@ export class RolesPage extends TablePage<Role> {
},
}),
);
return html` <section class="pf-c-page__main-section pf-m-no-padding-mobile">
<div class="pf-c-sidebar pf-m-gutter">
<div class="pf-c-sidebar__main">
return html` <section class="pf-v6-c-page__main-section pf-m-no-padding-mobile">
<div class="pf-v6-c-sidebar pf-m-gutter">
<div class="pf-v6-c-sidebar__main">
${this.renderSidebarBefore()}
<a class="pf-c-sidebar__content pf-l-grid pf-m-gutter">
<a class="pf-v6-c-sidebar__content pf-v6-l-grid pf-m-gutter">
${this.data?.results.map((role) => {
const card = html` <div
class="pf-c-card ${role.settingsAvailable
class="pf-v6-c-card ${role.settingsAvailable
? "pf-m-hoverable-raised"
: ""} pf-l-grid__item pf-m-3-col"
: ""} pf-v6-l-grid__item pf-m-3-col"
@click=${() => {
if (!role.settingsAvailable) {
return;
Expand All @@ -166,8 +166,8 @@ export class RolesPage extends TablePage<Role> {
}}
slot="trigger"
>
<div class="pf-c-card__title">${role.name}</div>
<div class="pf-c-card__body">
<div class="pf-v6-c-card__title">${role.name}</div>
<div class="pf-v6-c-card__body">
<ak-chip-group
>${this.instances
.filter((inst) => inst.roles?.includes(role.id))
Expand Down

0 comments on commit b83fd9a

Please sign in to comment.