Skip to content

Commit

Permalink
Merge branch 'feature-oidc' into oauth-various-fixes-01
Browse files Browse the repository at this point in the history
  • Loading branch information
alesstimec authored Apr 4, 2024
2 parents 8d5e945 + 941c3cc commit a662c48
Show file tree
Hide file tree
Showing 12 changed files with 173 additions and 10 deletions.
17 changes: 17 additions & 0 deletions charms/jimm-k8s/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -100,3 +100,20 @@ options:
type: string
default: 24h
description: Expiry duration for authentication macaroons.
secure-session-cookies:
type: boolean
default: true
description: |
Whether HTTPS must be enabled to set session cookies.
session-cookie-max-age:
type: int
default: 86400
description: |
The max age for the session cookies in seconds, on subsequent logins, the session instance
extended by this amount.
final-redirect-url:
type: string
default: ""
description: |
The final redirect URL for JIMM to redirect to when completing a browser based
login. This should be your dashboard.
3 changes: 3 additions & 0 deletions charms/jimm-k8s/src/charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,9 @@ def _update_workload(self, event):
"JIMM_OAUTH_CLIENT_ID": oauth_provider_info.client_id,
"JIMM_OAUTH_CLIENT_SECRET": oauth_provider_info.client_secret,
"JIMM_OAUTH_SCOPES": oauth_provider_info.scope,
"JIMM_DASHBOARD_FINAL_REDIRECT_URL:": self.config.get("final-redirect-url"),
"JIMM_SECURE_SESSION_COOKIES:": self.config.get("secure-session-cookies"),
"JIMM_SESSION_COOKIE_MAX_AGE:": self.config.get("session-cookie-max-age"),
}
if self._state.dsn:
config_values["JIMM_DSN"] = self._state.dsn
Expand Down
5 changes: 5 additions & 0 deletions charms/jimm-k8s/tests/unit/test_charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"public-key": "izcYsQy3TePp6bLjqOo3IRPFvkQd2IKtyODGqC6SdFk=",
"private-key": "ly/dzsI9Nt/4JxUILQeAX79qZ4mygDiuYGqc2ZEiDEc=",
"vault-access-address": "10.0.1.123",
"final-redirect-url": "some-url",
}

EXPECTED_ENV = {
Expand All @@ -54,6 +55,9 @@
"JIMM_OAUTH_CLIENT_ID": OAUTH_CLIENT_ID,
"JIMM_OAUTH_CLIENT_SECRET": OAUTH_CLIENT_SECRET,
"JIMM_OAUTH_SCOPES": OAUTH_PROVIDER_INFO["scope"],
"JIMM_DASHBOARD_FINAL_REDIRECT_URL:": "some-url",
"JIMM_SECURE_SESSION_COOKIES:": True,
"JIMM_SESSION_COOKIE_MAX_AGE:": 86400,
}


Expand Down Expand Up @@ -218,6 +222,7 @@ def test_bakery_configuration(self):
"candid-agent-private-key": "test-private-key",
"public-key": "izcYsQy3TePp6bLjqOo3IRPFvkQd2IKtyODGqC6SdFk=",
"private-key": "ly/dzsI9Nt/4JxUILQeAX79qZ4mygDiuYGqc2ZEiDEc=",
"final-redirect-url": "some-url",
}
)

Expand Down
17 changes: 17 additions & 0 deletions charms/jimm/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,20 @@ options:
Expiry duration for JIMM session tokens. These tokens are used
by clients and their expiry determines how frequently a user
must login.
secure-session-cookies:
type: boolean
default: true
description: |
Whether HTTPS must be enabled to set session cookies.
session-cookie-max-age:
type: int
default: 86400
description: |
The max age for the session cookies in seconds, on subsequent logins, the session instance
extended by this amount.
final-redirect-url:
type: string
default: ""
description: |
The final redirect URL for JIMM to redirect to when completing a browser based
login. This should be your dashboard.
3 changes: 3 additions & 0 deletions charms/jimm/src/charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,9 @@ def _on_config_changed(self, _):
"jwt_expiry": self.config.get("jwt-expiry", "5m"),
"macaroon_expiry_duration": self.config.get("macaroon-expiry-duration"),
"session_expiry_duration": self.config.get("session-expiry-duration"),
"secure_session_cookies": self.config.get("secure-session-cookies"),
"session_cookie_max_age": self.config.get("session-cookie-max-age"),
"final_redirect_url": self.config.get("final-redirect-url"),
}

self.oauth.update_client_config(client_config=self._oauth_client_config)
Expand Down
3 changes: 3 additions & 0 deletions charms/jimm/templates/jimm.env
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,6 @@ JIMM_JWT_EXPIRY={{jwt_expiry}}
{% endif %}
JIMM_MACAROON_EXPIRY_DURATION={{macaroon_expiry_duration}}
JIMM_ACCESS_TOKEN_EXPIRY_DURATION={{session_expiry_duration}}
JIMM_SECURE_SESSION_COOKIES={{secure_session_cookies}}
JIMM_SESSION_COOKIE_MAX_AGE={{session_cookie_max_age}}
JIMM_DASHBOARD_FINAL_REDIRECT_URL={{final_redirect_url}}
23 changes: 16 additions & 7 deletions charms/jimm/tests/test_charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,13 +172,16 @@ def test_config_changed(self):
"audit-log-retention-period-in-days": "10",
"jwt-expiry": "10m",
"macaroon-expiry-duration": "48h",
"secure-session-cookies": True,
"session-cookie-max-age": 86400,
"final-redirect-url": "",
}
)
self.assertTrue(os.path.exists(config_file))
with open(config_file) as f:
lines = f.readlines()
os.unlink(config_file)
self.assertEqual(len(lines), 22)
self.assertEqual(len(lines), 25)
self.assertEqual(lines[0].strip(), "BAKERY_AGENT_FILE=")
self.assertEqual(lines[1].strip(), "CANDID_URL=https://candid.example.com")
self.assertEqual(lines[2].strip(), "JIMM_ADMINS=user1 user2 group1")
Expand Down Expand Up @@ -222,13 +225,16 @@ def test_config_changed_redirect_to_dashboard(self):
"private-key": "ly/dzsI9Nt/4JxUILQeAX79qZ4mygDiuYGqc2ZEiDEc=",
"audit-log-retention-period-in-days": "10",
"macaroon-expiry-duration": "48h",
"secure-session-cookies": True,
"session-cookie-max-age": 86400,
"final-redirect-url": "",
}
)
self.assertTrue(os.path.exists(config_file))
with open(config_file) as f:
lines = f.readlines()
os.unlink(config_file)
self.assertEqual(len(lines), 22)
self.assertEqual(len(lines), 25)
self.assertEqual(lines[0].strip(), "BAKERY_AGENT_FILE=")
self.assertEqual(lines[1].strip(), "CANDID_URL=https://candid.example.com")
self.assertEqual(lines[2].strip(), "JIMM_ADMINS=user1 user2 group1")
Expand Down Expand Up @@ -270,13 +276,16 @@ def test_config_changed_ready(self):
"private-key": "ly/dzsI9Nt/4JxUILQeAX79qZ4mygDiuYGqc2ZEiDEc=",
"audit-log-retention-period-in-days": "10",
"macaroon-expiry-duration": "48h",
"secure-session-cookies": True,
"session-cookie-max-age": 86400,
"final-redirect-url": "",
}
)
self.assertTrue(os.path.exists(config_file))
with open(config_file) as f:
lines = f.readlines()
os.unlink(config_file)
self.assertEqual(len(lines), 20)
self.assertEqual(len(lines), 23)
self.assertEqual(lines[0].strip(), "BAKERY_AGENT_FILE=")
self.assertEqual(lines[1].strip(), "CANDID_URL=https://candid.example.com")
self.assertEqual(lines[2].strip(), "JIMM_ADMINS=user1 user2 group1")
Expand Down Expand Up @@ -329,7 +338,7 @@ def test_config_changed_with_agent(self):

with open(config_file) as f:
lines = f.readlines()
self.assertEqual(len(lines), 20)
self.assertEqual(len(lines), 23)
self.assertEqual(
lines[0].strip(),
"BAKERY_AGENT_FILE=" + self.harness.charm._agent_filename,
Expand All @@ -356,7 +365,7 @@ def test_config_changed_with_agent(self):
)
with open(config_file) as f:
lines = f.readlines()
self.assertEqual(len(lines), 20)
self.assertEqual(len(lines), 23)
self.assertEqual(lines[0].strip(), "BAKERY_AGENT_FILE=")
self.assertEqual(lines[1].strip(), "CANDID_URL=https://candid.example.com")
self.assertEqual(lines[2].strip(), "JIMM_ADMINS=user1 user2 group1")
Expand Down Expand Up @@ -654,14 +663,14 @@ def test_insecure_secret_storage(self):
with open(config_file) as f:
lines = f.readlines()
os.unlink(config_file)
self.assertEqual(len(lines), 22)
self.assertEqual(len(lines), 25)
self.assertEqual(len([match for match in lines if "INSECURE_SECRET_STORAGE" in match]), 0)
self.harness.update_config({"postgres-secret-storage": True})
self.assertTrue(os.path.exists(config_file))
with open(config_file) as f:
lines = f.readlines()
os.unlink(config_file)
self.assertEqual(len(lines), 24)
self.assertEqual(len(lines), 27)
self.assertEqual(len([match for match in lines if "INSECURE_SECRET_STORAGE" in match]), 1)


Expand Down
80 changes: 80 additions & 0 deletions doc/jimm-auth.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# OAuth, JIMM and OIDC


## Introduction
JIMM has introduced OAuth for federated authentication, i.e., the ability to sign in via an external identity provider. The flow used is [authorisation code](https://auth0.com/docs/get-started/authentication-and-authorization-flow/authorization-code-flow). On top of this, JIMM now uses [OpenID Connect](https://www.microsoft.com/en-us/security/business/security-101/what-is-openid-connect-oidc#:~:text=and%20use%20cases-,OpenID%20Connect%20(OIDC)%20defined,in%20to%20access%20digital%20services.).

To perform a login against JIMM using the authorisation code flow from a browser, there are 4 HTTP endpoints available and 1 websocket facade call.

## Performing a login (HTTP)
### HTTP /auth/login GET
This will perform a a temporary redirect (307) to the /auth endpoint of JAAS' OAuth capable IdP server. The user will then be expected to login using any of the configured methods on the OAuth server, such as social sign in (e.g. Sign in with Google/Github/etc) or self service.

### HTTP /auth/callback REDIRECT
Upon a successful login, the OAuth server will redirect back to JIMM's callback endpoint.

This endpoint will do the following:
1. Authenticate the user with the OAuth server
2. Create a session for the user within JIMM's database
3. Create and return an encrypted cookie containing the session information
4. Redirect the user back to a configurable final redirect URL (likely the Juju dashboard)
5. Attempt to extract the email claim from the id_token
6. Create a session within JIMM's internal database and then attach an encrypted cookie containing the session identity ID to the response for the final redirect called "jimm-browser-session", finally, jimm redirect back the the configured final redirect URL (which is likely to be the Juju dashboard)

> Note: The cookie returned will have HTTP Only set to false, enabling SPA's (Single Page Application) to read the cookie.
After receiving the redirect from JIMM, the browser will now store the cookie and it can be used for the next steps.

## Performing authentication (HTTP and WS)
### HTTP /auth/whoami GET
To confirm the identity that has been logged in from the cookie that has been returned in the final callback, the consumer will need to perform a get request to this endpoint. This endpoint will return (when a cookie can successfully be parsed into an application session and it is valid):
```json
{
"display-name": "<string>",
"email": "<string>"
}
```

In addition to this, the whoami endpoint will extend the users session by the configured max age field on the JIMM server, returning an updated cookie.

If no cookie is provided, a status Forbidden 403 will be returned, informing the consumer that they have no session cookie.

In the event of an internal server error, a status Internal Server Error 500 will be returned.

### WS /api and /api/{model id} WS PROTOCOL
The facade details to login are as follows:
- Facade name: `Admin`
- Version: `4 and above`
- Method name: `LoginWithSessionCookie`
- Parameters: `None`

The cookie header must be present on the initial request to open the websocket and must contain the cookie "jimm-browser-session", which holds the encrypted session identity that was returned in `/auth/callback`.

## Performing a logout (HTTP)
### /auth/logout GET
To logout, simply hit this endpoint.

If no cookie is present, a status Forbidden 403 will be returned, informating the consumer that they have no session cookie.

In the event of an internal server error, a status Internal Server Error 500 will be returned.

Otherwise, a status OK 200 will be returned, which will reset the cookies max-age to -1, informing the browser to remove the session cookie immediately.

# Sessions
## The kind of sessions

### IdP Sessions
The IdP will hold a session for the authenticated user, meaning, that should another OAuth
flow be processed, if the user has already entered their credentials, and a session is active, they will not be expected to enter them again until the IdP session expires.

This means, should the user be redirected to the IdP's login page, they'll only have to perform a consent and not enter their credentials (if consent is enabled), and then they will be immediately redirected to the configured redirect URI callback.

### OAuth Sessions
OAuth sessions are often referred to as offline sessions, which directly relates to the use
of the [offline_access](https://openid.net/specs/openid-connect-core-1_0.html#OfflineAccess) scope. The access token expiry is used to determine if an OAuth session is currently active, and to be refreshed is handled by the IdP's offline_access idle timeout and/or expiry. If the refresh tokens idle timeout is reached, the token is revoked and any existing access tokens are also revoked.

For an example of how keycloak handles this, see [here](https://wjw465150.gitbooks.io/keycloak-documentation/content/server_admin/topics/sessions/offline.html).

### Application Sessions
Application sessions are sessions between the client and the application, they may use means such as a JWT, cookie or some other means to authenticate the user for some period of time. The refreshing of these sessions is dependent on the OAuth session, and whether it is still valid.

6 changes: 6 additions & 0 deletions internal/auth/oauth2.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,17 +91,23 @@ type AuthenticationServiceParams struct {
// IssuerURL is the URL of the OAuth2.0 server.
// I.e., http://localhost:8082/realms/jimm in the case of keycloak.
IssuerURL string

// ClientID holds the OAuth2.0 client id. The client IS expected to be confidential.
ClientID string

// ClientSecret holds the OAuth2.0 "client-secret" to authenticate when performing
// /auth and /token requests.
ClientSecret string

// Scopes holds the scopes that you wish to retrieve.
Scopes []string

// SessionTokenExpiry holds the expiry time of minted JIMM session tokens (JWTs).
SessionTokenExpiry time.Duration

// SessionCookieMaxAge holds the max age for session cookies in seconds.
SessionCookieMaxAge int

// RedirectURL is the URL for handling the exchange of authorisation
// codes into access tokens (and id tokens), for JIMM, this is expected
// to be the servers own callback endpoint registered under /auth/callback.
Expand Down
8 changes: 7 additions & 1 deletion internal/jimmhttp/auth_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ import (
"github.com/canonical/jimm/internal/errors"
)

// CallbackEndpoint holds the endpoint path for OAuth2.0 authorisation
// flow callbacks.
const (
CallbackEndpoint = "/callback"
)

// OAuthHandler handles the oauth2.0 browser flow for JIMM.
// Implements jimmhttp.JIMMHttpHandler.
type OAuthHandler struct {
Expand Down Expand Up @@ -79,7 +85,7 @@ func NewOAuthHandler(p OAuthHandlerParams) (*OAuthHandler, error) {
func (oah *OAuthHandler) Routes() chi.Router {
oah.SetupMiddleware()
oah.Router.Get("/login", oah.Login)
oah.Router.Get("/callback", oah.Callback)
oah.Router.Get(CallbackEndpoint, oah.Callback)
oah.Router.Get("/logout", oah.Logout)
oah.Router.Get("/whoami", oah.Whoami)
return oah.Router
Expand Down
4 changes: 3 additions & 1 deletion local/keycloak/jimm-realm.json
Original file line number Diff line number Diff line change
Expand Up @@ -806,7 +806,9 @@
"clientAuthenticatorType": "client-secret",
"secret": "SwjDofnbDzJDm9iyfUhEp67FfUFMY8L4",
"redirectUris": [
"http://127.0.0.1/*"
"http://127.0.0.1/*",
"https://jimm.localhost/*",
"https://localhost/*"
],
"webOrigins": [
"*"
Expand Down
14 changes: 13 additions & 1 deletion service.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,16 +65,21 @@ type OAuthAuthenticatorParams struct {
// IssuerURL is the URL of the OAuth2.0 server.
// I.e., http://localhost:8082/realms/jimm in the case of keycloak.
IssuerURL string

// ClientID holds the OAuth2.0. The client IS expected to be confidential.
ClientID string

// ClientSecret holds the OAuth2.0 "client-secret" to authenticate when performing
// /auth and /token requests.
ClientSecret string

// Scopes holds the scopes that you wish to retrieve.
Scopes []string

// SessionTokenExpiry holds the expiry duration for issued JWTs
// for user (CLI) to JIMM authentication.
SessionTokenExpiry time.Duration

// SessionCookieMaxAge holds the max age for session cookies in seconds.
SessionCookieMaxAge int
}
Expand Down Expand Up @@ -288,6 +293,12 @@ func NewService(ctx context.Context, p Params) (*Service, error) {
return nil, errors.E(op, err, "failed to ensure controller admins")
}

authResourceBasePath := "/auth"
redirectUrl := p.PublicDNSName + authResourceBasePath + jimmhttp.CallbackEndpoint
if !strings.HasPrefix(redirectUrl, "https://") || !strings.HasPrefix(redirectUrl, "http://") {
redirectUrl = "https://" + redirectUrl
}

authSvc, err := auth.NewAuthenticationService(
ctx,
auth.AuthenticationServiceParams{
Expand All @@ -299,6 +310,7 @@ func NewService(ctx context.Context, p Params) (*Service, error) {
SessionCookieMaxAge: p.OAuthAuthenticatorParams.SessionCookieMaxAge,
Store: &s.jimm.Database,
SessionStore: sessionStore,
RedirectURL: redirectUrl,
},
)
s.jimm.OAuthAuthenticator = authSvc
Expand Down Expand Up @@ -359,7 +371,7 @@ func NewService(ctx context.Context, p Params) (*Service, error) {
return nil, errors.E(op, err, "failed to setup authentication handler")
}
mountHandler(
"/auth",
authResourceBasePath,
oauthHandler,
)
macaroonDischarger, err := s.setupDischarger(p)
Expand Down

0 comments on commit a662c48

Please sign in to comment.