Skip to content

Commit

Permalink
feat: add endpoint for refreshing expired gitlab tokens (#613)
Browse files Browse the repository at this point in the history
  • Loading branch information
olevski committed Dec 7, 2022
1 parent 5acf293 commit 8d0c2eb
Show file tree
Hide file tree
Showing 11 changed files with 113 additions and 34 deletions.
9 changes: 5 additions & 4 deletions app/auth/cli_auth.py
Expand Up @@ -35,7 +35,6 @@

blueprint = Blueprint("cli_auth", __name__, url_prefix="/auth/cli")

CLI_SUFFIX = "cli_oauth_client"
SCOPE = ["profile", "email", "openid"]


Expand Down Expand Up @@ -67,16 +66,18 @@ def login():
return handle_login_request(
provider_app,
urljoin(current_app.config["HOST_NAME"], url_for("cli_auth.token")),
CLI_SUFFIX,
current_app.config["CLI_SUFFIX"],
SCOPE,
)


@blueprint.route("/token")
def token():
response, _ = handle_token_request(request, CLI_SUFFIX)
response, _ = handle_token_request(request, current_app.config["CLI_SUFFIX"])

client_redis_key = get_redis_key_from_session(key_suffix=CLI_SUFFIX)
client_redis_key = get_redis_key_from_session(
key_suffix=current_app.config["CLI_SUFFIX"]
)
cli_nonce = session.get("cli_nonce")
if cli_nonce:
server_nonce = session.get("server_nonce")
Expand Down
28 changes: 25 additions & 3 deletions app/auth/gitlab_auth.py
Expand Up @@ -23,20 +23,23 @@
from flask import (
Blueprint,
current_app,
jsonify,
redirect,
render_template,
request,
url_for,
)

from ..config import GL_SUFFIX
from .oauth_client import RenkuWebApplicationClient
from .oauth_provider_app import GitLabProviderApp
from .utils import (
get_redis_key_from_token,
handle_login_request,
handle_token_request,
keycloak_authenticated,
)

GL_SUFFIX = "gl_oauth_client"

blueprint = Blueprint("gitlab_auth", __name__, url_prefix="/auth/gitlab")

Expand Down Expand Up @@ -81,14 +84,14 @@ def login():
return handle_login_request(
provider_app,
urljoin(current_app.config["HOST_NAME"], url_for("gitlab_auth.token")),
GL_SUFFIX,
current_app.config["GL_SUFFIX"],
SCOPE,
)


@blueprint.route("/token")
def token():
response, _ = handle_token_request(request, GL_SUFFIX)
response, _ = handle_token_request(request, current_app.config["GL_SUFFIX"])
return response


Expand All @@ -105,3 +108,22 @@ def logout():
response = render_template("gitlab_logout.html", logout_url=logout_url)

return response


@blueprint.route("/exchange", methods=["GET"])
@keycloak_authenticated
def exchange(*, sub, access_token):
"""Exchange a keycloak JWT for a valid Gitlab oauth token."""
redis_key = get_redis_key_from_token(
access_token, key_suffix=current_app.config["GL_SUFFIX"]
)
# NOTE: getting the client from the redis store automatically refreshes if needed
gl_oauth_client: RenkuWebApplicationClient = current_app.store.get_oauth_client(
redis_key
)
return jsonify(
{
"access_token": gl_oauth_client.access_token,
"expires_at": gl_oauth_client._expires_at,
},
)
8 changes: 6 additions & 2 deletions app/auth/notebook_auth.py
Expand Up @@ -19,18 +19,20 @@
import json
import re
from base64 import b64encode
from typing import List

from flask import current_app

from .gitlab_auth import GL_SUFFIX
from .oauth_client import RenkuWebApplicationClient
from .utils import get_redis_key_from_token, get_or_set_keycloak_client
from .web import KC_SUFFIX
from ..config import KC_SUFFIX

# TODO: This is a temporary implementation of the header interface defined in #404
# TODO: that allowes first clients to transition.


def get_git_credentials_header(git_oauth_clients):
def get_git_credentials_header(git_oauth_clients: List[RenkuWebApplicationClient]):
"""
Create the git authentication header as defined in #406
(https://github.com/SwissDataScienceCenter/renku-gateway/issues/406)
Expand All @@ -42,6 +44,7 @@ def get_git_credentials_header(git_oauth_clients):
# TODO: add this information to the provider_app and read it from there.
"provider": "GitLab",
"AuthorizationHeader": f"bearer {client.access_token}",
"AccessTokenExpiresAt": client._expires_at,
}
for client in git_oauth_clients
}
Expand All @@ -66,6 +69,7 @@ def process(self, request, headers):
)

headers["Renku-Auth-Access-Token"] = access_token
headers["Renku-Auth-Refresh-Token"] = keycloak_oidc_client.refresh_token
headers["Renku-Auth-Id-Token"] = keycloak_oidc_client.token["id_token"]
headers["Renku-Auth-Git-Credentials"] = get_git_credentials_header(
[gitlab_oauth_client]
Expand Down
2 changes: 1 addition & 1 deletion app/auth/renku_auth.py
Expand Up @@ -28,7 +28,7 @@
get_redis_key_from_token,
get_or_set_keycloak_client,
)
from .web import KC_SUFFIX
from ..config import KC_SUFFIX

# TODO: We're using a class here only to have a uniform interface
# with GitlabUserToken and JupyterhubUserToken. This should be refactored.
Expand Down
38 changes: 36 additions & 2 deletions app/auth/utils.py
Expand Up @@ -18,13 +18,16 @@

import hashlib
import random
import re
import secrets
import string
from functools import wraps
from urllib.parse import urljoin

import jwt
from flask import current_app, redirect, session, url_for
from flask import current_app, redirect, request, session, url_for

from ..config import KEYCLOAK_JWKS_CLIENT
from .oauth_client import RenkuWebApplicationClient
from .oauth_provider_app import KeycloakProviderApp

Expand All @@ -37,7 +40,7 @@ def decode_keycloak_jwt(token):
return jwt.decode(
token,
current_app.config["OIDC_PUBLIC_KEY"],
algorithms=JWT_ALGORITHM,
algorithms=[JWT_ALGORITHM],
audience=current_app.config["OIDC_CLIENT_ID"],
)

Expand Down Expand Up @@ -102,6 +105,7 @@ def handle_token_request(request, key_suffix):
urljoin(current_app.config["HOST_NAME"], url_for("web_auth.login_next"))
)
)

return response, oauth_client


Expand Down Expand Up @@ -133,3 +137,33 @@ def get_or_set_keycloak_client(redis_key: str) -> RenkuWebApplicationClient:
)
current_app.store.set_oauth_client(redis_key, oauth_client)
return oauth_client


def keycloak_authenticated(f):
"""Looks for a JWT in the Authorization header in the form of a bearer token.
Will raise an exception if the JWT is not valid or it has expired. If the token
is valid, it injects the 'sub' claim of the JWT and the encoded JWT in the function
as a keyword arguments. The names for the arguments are 'sub' and 'access_token'
for the sub-claim and access_token respectively."""

@wraps(f)
def decorated(*args, **kwargs):
m = re.search(
r"^bearer (?P<token>.+)",
request.headers.get("Authorization", ""),
re.IGNORECASE,
)
if m:
access_token = m.group("token")
signing_key = KEYCLOAK_JWKS_CLIENT.get_signing_key_from_jwt(access_token)
data = jwt.decode(
access_token,
key=signing_key.key,
algorithms=[JWT_ALGORITHM],
audience=current_app.config["OIDC_CLIENT_ID"],
)
return f(*args, **kwargs, sub=data["sub"], access_token=access_token)

raise Exception("Not authenticated")

return decorated
29 changes: 20 additions & 9 deletions app/auth/web.py
Expand Up @@ -30,7 +30,7 @@
url_for,
)

from .cli_auth import CLI_SUFFIX, handle_cli_token_request
from .cli_auth import handle_cli_token_request
from .oauth_provider_app import KeycloakProviderApp
from .utils import (
TEMP_SESSION_KEY,
Expand All @@ -44,7 +44,6 @@

blueprint = Blueprint("web_auth", __name__, url_prefix="/auth")

KC_SUFFIX = "kc_oidc_client"
SCOPE = ["profile", "email", "openid"]


Expand All @@ -59,9 +58,13 @@ def get_valid_token(headers):

if authorization_match:
renku_token = authorization_match.group("token")
redis_key = get_redis_key_from_token(renku_token, key_suffix=CLI_SUFFIX)
redis_key = get_redis_key_from_token(
renku_token, key_suffix=current_app.config["CLI_SUFFIX"]
)
elif headers.get("X-Requested-With") == "XMLHttpRequest" and "sub" in session:
redis_key = get_redis_key_from_session(key_suffix=KC_SUFFIX)
redis_key = get_redis_key_from_session(
key_suffix=current_app.config["KC_SUFFIX"]
)
else:
return None

Expand Down Expand Up @@ -122,24 +125,30 @@ def login():
return handle_login_request(
provider_app,
urljoin(current_app.config["HOST_NAME"], url_for("web_auth.token")),
KC_SUFFIX,
current_app.config["KC_SUFFIX"],
SCOPE,
)


@blueprint.route("/token")
def token():
response, keycloak_oidc_client = handle_token_request(request, KC_SUFFIX)
response, keycloak_oidc_client = handle_token_request(
request, current_app.config["KC_SUFFIX"]
)

# Get rid of the temporarily stored oauth client object in redis
# and the reference to it in the session.
old_redis_key = get_redis_key_from_session(key_suffix=KC_SUFFIX)
old_redis_key = get_redis_key_from_session(
key_suffix=current_app.config["KC_SUFFIX"]
)
current_app.store.delete(old_redis_key)
session.pop(TEMP_SESSION_KEY, None)

# Store the oauth client object in redis under the regular "sub" claim.
session["sub"] = decode_keycloak_jwt(keycloak_oidc_client.access_token)["sub"]
new_redis_key = get_redis_key_from_session(key_suffix=KC_SUFFIX)
new_redis_key = get_redis_key_from_session(
key_suffix=current_app.config["KC_SUFFIX"]
)
current_app.store.set_oauth_client(new_redis_key, keycloak_oidc_client)

return response
Expand Down Expand Up @@ -194,7 +203,9 @@ def logout():
if "sub" in session:
# NOTE: Do not delete GL client because CLI login uses it for authentication
# current_app.store.delete(get_redis_key_from_session(key_suffix=GL_SUFFIX))
current_app.store.delete(get_redis_key_from_session(key_suffix=KC_SUFFIX))
current_app.store.delete(
get_redis_key_from_session(key_suffix=current_app.config["KC_SUFFIX"])
)
session.clear()

logout_pages = []
Expand Down
22 changes: 15 additions & 7 deletions app/config.py
Expand Up @@ -21,6 +21,9 @@
import sys
import warnings

import jwt
from urllib.parse import urljoin

# This setting can force tokens to be refreshed in case
# they are issued with a too long or unlimited lifetime.
# This is currently the case for BOTH JupyterHub and GitLab!
Expand Down Expand Up @@ -86,14 +89,9 @@
"WEBHOOK_SERVICE_HOSTNAME", "http://renku-graph-webhooks-service"
)

KEYCLOAK_URL = os.environ.get("KEYCLOAK_URL")
if not KEYCLOAK_URL:
warnings.warn(
"The environment variable KEYCLOAK_URL is not set. "
"It is necessary because Keycloak acts as identity provider for Renku."
)
KEYCLOAK_URL = os.environ.get("KEYCLOAK_URL", HOST_NAME.strip("/") + "/auth").strip("/")
KEYCLOAK_REALM = os.environ.get("KEYCLOAK_REALM", "Renku")
OIDC_ISSUER = "{}/auth/realms/{}".format(KEYCLOAK_URL, KEYCLOAK_REALM)
OIDC_ISSUER = "{}/realms/{}".format(KEYCLOAK_URL, KEYCLOAK_REALM)
OIDC_CLIENT_ID = os.environ.get("OIDC_CLIENT_ID", "renku")
OIDC_CLIENT_SECRET = os.environ.get("OIDC_CLIENT_SECRET")
if not OIDC_CLIENT_SECRET:
Expand All @@ -110,4 +108,14 @@
os.environ.get("LOGOUT_GITLAB_UPON_RENKU_LOGOUT", "") == "true"
)

KEYCLOAK_JWKS_CLIENT = jwt.PyJWKClient(
urljoin(
KEYCLOAK_URL + "/", f"realms/{KEYCLOAK_REALM}/protocol/openid-connect/certs"
)
)

GL_SUFFIX = "gl_oauth_client"
CLI_SUFFIX = "cli_oauth_client"
KC_SUFFIX = "kc_oidc_client"

DEBUG = os.environ.get("DEBUG", "false") == "true"
6 changes: 2 additions & 4 deletions app/tests/test_proxy.py
Expand Up @@ -23,8 +23,6 @@
import responses

from .. import app
from ..auth.cli_auth import CLI_SUFFIX
from ..auth.gitlab_auth import GL_SUFFIX
from ..auth.oauth_client import RenkuWebApplicationClient
from ..auth.oauth_provider_app import OAuthProviderApp
from ..auth.oauth_redis import OAuthRedis
Expand Down Expand Up @@ -102,9 +100,9 @@ def set_dummy_oauth_client(token, key_suffix):

app.store = OAuthRedis(hex_key=app.config["SECRET_KEY"])

set_dummy_oauth_client(access_token, CLI_SUFFIX)
set_dummy_oauth_client(access_token, app.config["CLI_SUFFIX"])

set_dummy_oauth_client("some_token", GL_SUFFIX)
set_dummy_oauth_client("some_token", app.config["GL_SUFFIX"])

rv = client.get("/?auth=gitlab", headers=headers)

Expand Down
2 changes: 1 addition & 1 deletion helm-chart/renku-gateway/templates/configmap.yaml
Expand Up @@ -170,7 +170,7 @@ data:
[http.middlewares.auth-notebook.forwardAuth]
address = "http://{{ template "gateway.fullname" . }}-auth/?auth=notebook"
trustForwardHeader = true
authResponseHeaders = ["Renku-Auth-Access-Token", "Renku-Auth-Id-Token", "Renku-Auth-Git-Credentials", "Renku-Auth-Anon-Id"]
authResponseHeaders = ["Renku-Auth-Access-Token", "Renku-Auth-Id-Token", "Renku-Auth-Git-Credentials", "Renku-Auth-Anon-Id", "Renku-Auth-Refresh-Token"]
[http.middlewares.webhooks.replacePathRegex]
regex = "^/projects/([^/]*)/graph/webhooks(.*)"
Expand Down
2 changes: 1 addition & 1 deletion helm-chart/renku-gateway/templates/deployment.yaml
Expand Up @@ -56,7 +56,7 @@ spec:
- name: GITLAB_CLIENT_ID
value: {{ .Values.gitlabClientId | default .Values.global.gateway.gitlabClientId | quote }}
- name: KEYCLOAK_URL
value: {{ .Values.keycloakUrl | default (printf "%s://%s" (include "gateway.protocol" .) .Values.global.renku.domain) | quote }}
value: {{ .Values.keycloakUrl | default (printf "%s://%s/auth" (include "gateway.protocol" .) .Values.global.renku.domain) | quote }}
{{ if .Values.global.keycloak.realm }}
- name: KEYCLOAK_REALM
value: {{ .Values.global.keycloak.realm | quote }}
Expand Down
1 change: 1 addition & 0 deletions helm-chart/renku-gateway/values.yaml
Expand Up @@ -258,6 +258,7 @@ traefik:
Authorization: redact
Cookie: redact
Renku-Auth-Access-Token: redact
Renku-Auth-Refresh-Token: redact
Renku-Auth-Git-Credentials: redact
general:
format: json
Expand Down

0 comments on commit 8d0c2eb

Please sign in to comment.