diff --git a/app/__init__.py b/app/__init__.py index 934aa02..f7ba442 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,7 +1,7 @@ from flask import Flask from config import Config -from app.extensions import db, migrate +from app.extensions import db, migrate, oauth from app.storage import public_url @@ -12,6 +12,17 @@ def create_app(config_class=Config): db.init_app(app) migrate.init_app(app, db) + # Authlib loads the realm's OIDC discovery doc on first use to pick up + # endpoints and JWKS — no need to hard-code per-realm URLs. + oauth.init_app(app) + oauth.register( + name="keycloak", + server_metadata_url=f"{app.config['OIDC_ISSUER_URL'].rstrip('/')}/.well-known/openid-configuration", + client_id=app.config["OIDC_CLIENT_ID"], + client_secret=app.config["OIDC_CLIENT_SECRET"], + client_kwargs={"scope": "openid email profile"}, + ) + from app.auth import bp as auth_bp from app.library import bp as library_bp from app.admin import bp as admin_bp diff --git a/app/admin/routes.py b/app/admin/routes.py index 4f9867a..6689a7d 100644 --- a/app/admin/routes.py +++ b/app/admin/routes.py @@ -1,5 +1,4 @@ -from flask import abort, flash, redirect, render_template, request, session, url_for -from werkzeug.security import generate_password_hash +from flask import flash, redirect, render_template, session, url_for from sqlalchemy.orm import selectinload from app.admin import bp @@ -40,50 +39,14 @@ def users(): @admin_required def delete_user(user_id): user = User.query.get_or_404(user_id) - - # Prevent admin from deleting themselves + + # Self-protect: an admin shouldn't be able to wipe their own row out from + # under their session. Disabling in Keycloak is the right escape hatch. if user.id == session.get("user_id"): flash("You cannot delete your own account.", "error") return redirect(url_for("admin.users")) - - db.session.delete(user) - db.session.commit() - flash(f"User '{user.username}' has been deleted.", "success") - return redirect(url_for("admin.users")) - - -@bp.route("/admin/users//reset-password", methods=["POST"]) -@admin_required -def reset_password(user_id): - user = User.query.get_or_404(user_id) - new_password = request.form.get("new_password", "") - - if not new_password or len(new_password) < 6: - flash("Password must be at least 6 characters.", "error") - return redirect(url_for("admin.users")) - - user.password_hash = generate_password_hash(new_password) - db.session.commit() - flash(f"Password for '{user.username}' has been reset.", "success") - return redirect(url_for("admin.users")) - -@bp.route("/admin/users//change-role", methods=["POST"]) -@admin_required -def change_role(user_id): - user = User.query.get_or_404(user_id) - new_role = request.form.get("role", "borrower") - - if new_role not in ["borrower", "admin"]: - flash("Invalid role.", "error") - return redirect(url_for("admin.users")) - - # Prevent admin from changing their own role - if user.id == session.get("user_id"): - flash("You cannot change your own role.", "error") - return redirect(url_for("admin.users")) - - user.role = new_role + db.session.delete(user) db.session.commit() - flash(f"Role for '{user.username}' changed to '{new_role}'.", "success") + flash(f"Local mirror row for '{user.username}' deleted. Disable in Keycloak to block sign-in.", "success") return redirect(url_for("admin.users")) diff --git a/app/admin/templates/admin/dashboard.html b/app/admin/templates/admin/dashboard.html index 92fe34f..1955c05 100644 --- a/app/admin/templates/admin/dashboard.html +++ b/app/admin/templates/admin/dashboard.html @@ -31,7 +31,7 @@

Admin dashboard

{% endif %}

Manage Books — View, edit, and delete books in the library.

-

Manage Users — View, delete, and reset passwords for all registered users.

+

Manage Users — View the local mirror of users signed in via Keycloak. Passwords and roles are managed in Keycloak.

Borrower Dashboard — See all signed-up accounts and their borrowed books, borrow dates, due dates, and return dates.

This page is only visible to users with the admin role. Borrowers who try to visit it get a 403 Forbidden.

← Back home

diff --git a/app/admin/templates/admin/users.html b/app/admin/templates/admin/users.html index 1bbc15b..4af9232 100644 --- a/app/admin/templates/admin/users.html +++ b/app/admin/templates/admin/users.html @@ -78,7 +78,14 @@

Manage Users

← Back to dashboard

- + +

+ Identity is managed in Keycloak. Use the Keycloak admin console to + create users, reset passwords, or change roles. Deleting a row here + only removes the local mirror; the user can sign in again to recreate + it. +

+ {% with messages = get_flashed_messages(with_categories=true) %} {% if messages %} {% for category, message in messages %} @@ -86,7 +93,7 @@

Manage Users

{% endfor %} {% endif %} {% endwith %} - + {% if users %} @@ -105,23 +112,11 @@

Manage Users

- + diff --git a/app/auth/routes.py b/app/auth/routes.py index 317fc40..f6af083 100644 --- a/app/auth/routes.py +++ b/app/auth/routes.py @@ -1,80 +1,92 @@ -from flask import redirect, render_template, request, session, url_for, flash, current_app -from werkzeug.security import generate_password_hash, check_password_hash import logging +from urllib.parse import urlencode -logger = logging.getLogger(__name__) +from flask import current_app, redirect, request, session, url_for from app.auth import bp -from app.extensions import db -from app.forms import SignupForm +from app.extensions import db, oauth from app.models import User -@bp.route("/login", methods=["GET", "POST"]) +logger = logging.getLogger(__name__) + + +@bp.route("/login") def login(): - error = None - if request.method == "POST": - username = request.form.get("username", "").strip() - password = request.form.get("password", "") - - user = User.query.filter_by(username=username).first() - if user and check_password_hash(user.password_hash, password): - session["user_id"] = user.id - session["username"] = user.username - session["role"] = user.role - return redirect(url_for("library.index")) - - error = "Invalid username or password." - return render_template("auth/login.html", error=error) - - -@bp.route("/signup", methods=["GET", "POST"]) -def signup(): - form = SignupForm() - if request.method == "POST": - logger.info(f"POST data: {request.form}") - logger.info(f"Form validate_on_submit: {form.validate_on_submit()}") - logger.info(f"Form errors: {form.errors}") - - if form.validate_on_submit(): - username = form.username.data.strip() - email = form.email.data.strip() - password = form.password.data - - logger.info(f"Signup attempt: username={username}, email={email}") - - # Check if user already exists - if User.query.filter_by(username=username).first(): - flash("Username already exists.", "error") - return render_template("auth/signup.html", form=form) - - if User.query.filter_by(email=email).first(): - flash("Email already registered.", "error") - return render_template("auth/signup.html", form=form) - - # Create new user with hashed password - try: - new_user = User( - username=username, - email=email, - password_hash=generate_password_hash(password), - role="borrower" - ) - db.session.add(new_user) - db.session.commit() - - logger.info(f"User created successfully: id={new_user.id}, username={username}") - - flash("Account created! Please log in.", "success") - return redirect(url_for("auth.login")) - except Exception as e: - db.session.rollback() - logger.error(f"Error creating user: {e}") - flash(f"Error creating account: {str(e)}", "error") - - return render_template("auth/signup.html", form=form) + # Authlib generates state + PKCE verifier and stashes them in the session; + # `authorize_redirect` returns a 302 to Keycloak's /auth endpoint. + redirect_uri = url_for("auth.callback", _external=True) + return oauth.keycloak.authorize_redirect(redirect_uri) + + +@bp.route("/callback") +def callback(): + token = oauth.keycloak.authorize_access_token() + claims = token.get("userinfo") or {} + + sub = claims.get("sub") + if not sub: + logger.error("OIDC callback returned no sub claim: %s", claims) + return redirect(url_for("auth.login")) + + user = _upsert_user_from_claims(claims) + + session.clear() + session["user_id"] = user.id + session["username"] = user.username + session["role"] = user.role + # id_token kept for RP-initiated logout (`id_token_hint`). + session["id_token"] = token.get("id_token") + return redirect(url_for("library.index")) @bp.route("/logout", methods=["POST"]) def logout(): + id_token = session.get("id_token") session.clear() - return redirect(url_for("auth.login")) + + issuer = current_app.config.get("OIDC_ISSUER_URL") + if not (id_token and issuer): + return redirect(url_for("auth.login")) + + # RP-initiated logout: tells Keycloak to end its session too, otherwise + # the next /login bounces straight back without a credential prompt. + params = { + "id_token_hint": id_token, + "post_logout_redirect_uri": url_for("auth.login", _external=True), + } + end_session = f"{issuer.rstrip('/')}/protocol/openid-connect/logout" + return redirect(f"{end_session}?{urlencode(params)}") + + +def _upsert_user_from_claims(claims): + """Find-or-create the local mirror row for the authenticated Keycloak user. + + Lookup order is sub → email, so the existing seeded admin (no sub yet) + gets linked the first time they sign in via Keycloak. + """ + sub = claims["sub"] + email = claims.get("email") or f"{sub}@unknown.local" + username = claims.get("preferred_username") or email + + # Keycloak realm roles are surfaced into userinfo via a realm-roles mapper + # configured on the client (see the keycloak-config repo). Without that + # mapper, every user defaults to borrower. + admin_role = current_app.config["OIDC_ADMIN_ROLE"] + realm_roles = (claims.get("realm_access") or {}).get("roles", []) + role = "admin" if admin_role in realm_roles else "borrower" + + user = User.query.filter_by(keycloak_sub=sub).first() + if user is None: + user = User.query.filter_by(email=email).first() + + if user is None: + user = User(keycloak_sub=sub, username=username, email=email, role=role) + db.session.add(user) + else: + user.keycloak_sub = sub + user.username = username + user.email = email + user.role = role + + db.session.commit() + return user diff --git a/app/auth/templates/auth/login.html b/app/auth/templates/auth/login.html deleted file mode 100644 index 50d7627..0000000 --- a/app/auth/templates/auth/login.html +++ /dev/null @@ -1,102 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Login — TKC Library{% endblock %} -{% block body_class %}centered{% endblock %} - -{% block extra_styles %} - -{% endblock %} - -{% block content %} -
-
- - - - - TKC Library -
-

Login

- - {% if error %}
{{ error }}
{% endif %} - -
- - - - - - - - - - -
-{% endblock %} diff --git a/app/auth/templates/auth/signup.html b/app/auth/templates/auth/signup.html deleted file mode 100644 index 8ee0c5e..0000000 --- a/app/auth/templates/auth/signup.html +++ /dev/null @@ -1,148 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Sign Up — TKC Library{% endblock %} -{% block body_class %}centered{% endblock %} - -{% block extra_styles %} - -{% endblock %} - -{% block content %} -
-
- - - - - TKC Library -
-

Create Account

- - {% with messages = get_flashed_messages(with_categories=true) %} - {% if messages %} - {% for category, message in messages %} -
{{ message }}
- {% endfor %} - {% endif %} - {% endwith %} - -
- {{ form.hidden_tag() }} - - - {{ form.username(class="form-control", placeholder="Choose a username") }} - {% if form.username.errors %} - {% for error in form.username.errors %} -
{{ error }}
- {% endfor %} - {% endif %} - - - {{ form.email(class="form-control", placeholder="Enter your email") }} - {% if form.email.errors %} - {% for error in form.email.errors %} -
{{ error }}
- {% endfor %} - {% endif %} - - - {{ form.password(class="form-control", placeholder="Create a password (min 6 characters)") }} - {% if form.password.errors %} - {% for error in form.password.errors %} -
{{ error }}
- {% endfor %} - {% endif %} - - - {{ form.confirm_password(class="form-control", placeholder="Confirm your password") }} - {% if form.confirm_password.errors %} - {% for error in form.confirm_password.errors %} -
{{ error }}
- {% endfor %} - {% endif %} - - {{ form.submit(class="btn") }} - - - -
-{% endblock %} \ No newline at end of file diff --git a/app/extensions.py b/app/extensions.py index 378f0df..c335627 100644 --- a/app/extensions.py +++ b/app/extensions.py @@ -1,5 +1,7 @@ +from authlib.integrations.flask_client import OAuth from flask_sqlalchemy import SQLAlchemy from flask_migrate import Migrate db = SQLAlchemy() migrate = Migrate() +oauth = OAuth() diff --git a/app/forms.py b/app/forms.py index 49b2d0c..c2cbd01 100644 --- a/app/forms.py +++ b/app/forms.py @@ -1,6 +1,6 @@ from flask_wtf import FlaskForm -from wtforms import StringField, PasswordField, TextAreaField, SubmitField, HiddenField -from wtforms.validators import DataRequired, Length, Email, EqualTo, Optional, Regexp +from wtforms import StringField, TextAreaField, SubmitField, HiddenField +from wtforms.validators import DataRequired, Length, Optional, Regexp class BookForm(FlaskForm): @@ -14,11 +14,3 @@ class BookForm(FlaskForm): validators=[Optional(), Regexp(r"^books/[A-Za-z0-9._-]+$", message="Invalid photo key")], ) submit = SubmitField("Add Book") - - -class SignupForm(FlaskForm): - username = StringField("Username", validators=[DataRequired(), Length(min=3, max=80)]) - email = StringField("Email", validators=[DataRequired(), Email(), Length(max=120)]) - password = PasswordField("Password", validators=[DataRequired(), Length(min=6)]) - confirm_password = PasswordField("Confirm Password", validators=[DataRequired(), EqualTo("password")]) - submit = SubmitField("Sign Up") diff --git a/app/library/routes.py b/app/library/routes.py index 4c575f8..6305a01 100644 --- a/app/library/routes.py +++ b/app/library/routes.py @@ -2,7 +2,6 @@ from datetime import datetime, timezone, timedelta from sqlalchemy import or_ from sqlalchemy.orm import selectinload -from werkzeug.security import generate_password_hash from app.auth.decorators import login_required, admin_required from app.extensions import db @@ -12,23 +11,6 @@ from app.storage import presign_put, UnsupportedImageType, ALLOWED_IMAGE_TYPES -def _get_or_create_user(username): - if not username: - return None - - user = db.session.scalar(db.select(User).where(User.username == username)) - if not user: - user = User( - username=username, - email=f"{username}@library.local", - password_hash=generate_password_hash(username), - role=session.get("role", "borrower"), - ) - db.session.add(user) - db.session.commit() - return user - - @bp.route("/") @login_required def index(): @@ -108,16 +90,11 @@ def favorite_book(book_id): flash("Book not found.", "error") return redirect(url_for("library.index")) - username = session.get("username") - if not username: + user = db.session.get(User, session.get("user_id")) + if not user: flash("You must be logged in to favourite a book.", "error") return redirect(url_for("auth.login")) - user = _get_or_create_user(username) - if not user: - flash("Unable to find or create user.", "error") - return redirect(url_for("library.index")) - if book in user.favorites: user.favorites.remove(book) flash(f"Removed '{book.name}' from your favourites.", "success") @@ -310,25 +287,11 @@ def borrow_book(book_id): flash("Book not found.", "error") return redirect(url_for("library.index")) - # Get or create user from session username - username = session.get("username") - if not username: + user = db.session.get(User, session.get("user_id")) + if not user: flash("You must be logged in to borrow a book.", "error") return redirect(url_for("auth.login")) - - # Try to find user, create if doesn't exist - user = db.session.scalars(db.select(User).where(User.username == username)).first() - if not user: - # Create new user with default email based on username - user = User( - username=username, - email=f"{username}@library.local", - password_hash=generate_password_hash(username), - role="borrower", - ) - db.session.add(user) - db.session.commit() - + # Check if book is already borrowed active_borrow = db.session.scalars( db.select(Borrow).where( diff --git a/app/models.py b/app/models.py index ac43e7f..efbf63e 100644 --- a/app/models.py +++ b/app/models.py @@ -13,9 +13,15 @@ class User(db.Model): __tablename__ = "users" id = db.Column(db.Integer, primary_key=True) + # Stable Keycloak `sub` claim — the only identifier guaranteed not to + # change on email/username edits in the IdP. Nullable to allow the + # existing seeded admin row to be linked by email on first OIDC login. + keycloak_sub = db.Column(db.String(64), unique=True, nullable=True, index=True) username = db.Column(db.String(80), unique=True, nullable=False) email = db.Column(db.String(120), unique=True, nullable=False) - password_hash = db.Column(db.String(200), nullable=False) + # Nullable since Keycloak owns credentials. Kept around so the column can + # be dropped in a follow-up migration once nothing references it. + password_hash = db.Column(db.String(200), nullable=True) role = db.Column(db.String(20), default="borrower", nullable=False) created_at = db.Column( db.DateTime(timezone=True), diff --git a/config.py b/config.py index 2ff18f5..7e07071 100644 --- a/config.py +++ b/config.py @@ -18,3 +18,12 @@ class Config: S3_ENDPOINT_URL = os.environ.get("S3_ENDPOINT_URL") # unset in prod S3_PUBLIC_BASE_URL = os.environ.get("S3_PUBLIC_BASE_URL") S3_PRESIGN_TTL_SECONDS = int(os.environ.get("S3_PRESIGN_TTL_SECONDS", "300")) + + # Keycloak / OIDC. The issuer URL is the realm root — Authlib appends + # `/.well-known/openid-configuration` to discover endpoints and JWKS. + # OIDC_ADMIN_ROLE names the Keycloak realm role that grants admin in this + # app; anyone without it is treated as a borrower. + OIDC_ISSUER_URL = os.environ.get("OIDC_ISSUER_URL") + OIDC_CLIENT_ID = os.environ.get("OIDC_CLIENT_ID") + OIDC_CLIENT_SECRET = os.environ.get("OIDC_CLIENT_SECRET") + OIDC_ADMIN_ROLE = os.environ.get("OIDC_ADMIN_ROLE", "library-admin") diff --git a/docker-compose.yml b/docker-compose.yml index c4fb552..e0d89ff 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -55,6 +55,25 @@ services: echo 'MinIO bucket ready: '$$BUCKET " + # Local Keycloak for OIDC. Realm import file lives in the keycloak-config + # repo and gets dropped into ./keycloak-import/ before `docker compose up` + # (see README). Dev-mode start so HTTPS isn't required on localhost. + keycloak: + image: quay.io/keycloak/keycloak:25.0 + restart: unless-stopped + command: start-dev --import-realm + environment: + KEYCLOAK_ADMIN: admin + KEYCLOAK_ADMIN_PASSWORD: admin + KC_HOSTNAME_STRICT: "false" + KC_HTTP_ENABLED: "true" + ports: + - "8081:8080" + volumes: + - ./keycloak-import:/opt/keycloak/data/import:ro + - keycloak_data:/opt/keycloak/data + volumes: postgres_data: minio_data: + keycloak_data: diff --git a/migrations/versions/d1f5a2b9c0e4_add_keycloak_sub_to_users.py b/migrations/versions/d1f5a2b9c0e4_add_keycloak_sub_to_users.py new file mode 100644 index 0000000..f1b9a11 --- /dev/null +++ b/migrations/versions/d1f5a2b9c0e4_add_keycloak_sub_to_users.py @@ -0,0 +1,33 @@ +"""add keycloak_sub to users and relax password_hash + +Revision ID: d1f5a2b9c0e4 +Revises: b9c4d8e1a2f3 +Create Date: 2026-05-04 00:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + + +revision = 'd1f5a2b9c0e4' +down_revision = 'b9c4d8e1a2f3' +branch_labels = None +depends_on = None + + +def upgrade(): + # password_hash is no longer the source of truth — Keycloak owns + # credentials. Kept nullable rather than dropped so existing rows + # don't violate the constraint and the column can be removed in a + # follow-up once nothing reads it. + op.alter_column("users", "password_hash", existing_type=sa.String(length=200), nullable=True) + op.add_column("users", sa.Column("keycloak_sub", sa.String(length=64), nullable=True)) + op.create_unique_constraint("uq_users_keycloak_sub", "users", ["keycloak_sub"]) + op.create_index("ix_users_keycloak_sub", "users", ["keycloak_sub"]) + + +def downgrade(): + op.drop_index("ix_users_keycloak_sub", table_name="users") + op.drop_constraint("uq_users_keycloak_sub", "users", type_="unique") + op.drop_column("users", "keycloak_sub") + op.alter_column("users", "password_hash", existing_type=sa.String(length=200), nullable=False) diff --git a/requirements.txt b/requirements.txt index c221f09..a29d69e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,4 +5,5 @@ Flask-WTF>=1.2 psycopg[binary]>=3.1 python-dotenv>=1.0 email_validator -boto3>=1.34 \ No newline at end of file +boto3>=1.34 +Authlib>=1.3 diff --git a/terraform/main/envs/services/main.tf b/terraform/main/envs/services/main.tf index 9c6e1c5..482c8f8 100644 --- a/terraform/main/envs/services/main.tf +++ b/terraform/main/envs/services/main.tf @@ -17,6 +17,12 @@ module "app" { uploads_bucket_name = "tkc-librarian-uploads-services" + # Keycloak realm name needs to match the realm provisioned by the + # keycloak-config repo. `oidc_client_id` and the Keycloak-side client + # registration must agree. + oidc_issuer_url = "https://login.keyholding.com/realms/keyholding" + oidc_client_id = "tkc-library" + providers = { aws = aws aws.us_east_1 = aws.us_east_1 diff --git a/terraform/main/main.tf b/terraform/main/main.tf index 3cc5329..00d69fd 100644 --- a/terraform/main/main.tf +++ b/terraform/main/main.tf @@ -85,11 +85,17 @@ module "ecs" { secret_arns = [ module.secrets.rds_secret_arn, module.secrets.flask_secret_arn, + module.secrets.oidc_secret_arn, ] rds_secret_arn = module.secrets.rds_secret_arn flask_secret_arn = module.secrets.flask_secret_arn + oidc_secret_arn = module.secrets.oidc_secret_arn secrets_kms_key_arn = module.secrets.secrets_kms_key_arn + oidc_issuer_url = var.oidc_issuer_url + oidc_client_id = var.oidc_client_id + oidc_admin_role = var.oidc_admin_role + db_address = module.rds.address db_port = module.rds.port db_username = module.secrets.rds_username diff --git a/terraform/main/modules/ecs/main.tf b/terraform/main/modules/ecs/main.tf index 8c8b206..0e64118 100644 --- a/terraform/main/modules/ecs/main.tf +++ b/terraform/main/modules/ecs/main.tf @@ -117,6 +117,9 @@ resource "aws_ecs_task_definition" "api" { { name = "AWS_REGION", value = var.region }, { name = "S3_BUCKET", value = var.s3_uploads_bucket }, { name = "S3_PUBLIC_BASE_URL", value = var.s3_uploads_public_base_url }, + { name = "OIDC_ISSUER_URL", value = var.oidc_issuer_url }, + { name = "OIDC_CLIENT_ID", value = var.oidc_client_id }, + { name = "OIDC_ADMIN_ROLE", value = var.oidc_admin_role }, ] # `valueFrom` accepts a Secrets Manager ARN with a JSON-pointer suffix: @@ -125,6 +128,7 @@ resource "aws_ecs_task_definition" "api" { secrets = [ { name = "DB_PASSWORD", valueFrom = "${var.rds_secret_arn}:password::" }, { name = "SECRET_KEY", valueFrom = "${var.flask_secret_arn}:secret_key::" }, + { name = "OIDC_CLIENT_SECRET", valueFrom = "${var.oidc_secret_arn}:client_secret::" }, ] logConfiguration = { diff --git a/terraform/main/modules/ecs/variables.tf b/terraform/main/modules/ecs/variables.tf index 58f9ee9..a888611 100644 --- a/terraform/main/modules/ecs/variables.tf +++ b/terraform/main/modules/ecs/variables.tf @@ -34,6 +34,27 @@ variable "flask_secret_arn" { description = "Secrets Manager ARN for the Flask SECRET_KEY" } +variable "oidc_secret_arn" { + type = string + description = "Secrets Manager ARN for the Keycloak OIDC client_secret" +} + +variable "oidc_issuer_url" { + type = string + description = "OIDC issuer URL (Keycloak realm root, e.g. https://login.keyholding.com/realms/)" +} + +variable "oidc_client_id" { + type = string + description = "OIDC client_id registered in Keycloak for this app" +} + +variable "oidc_admin_role" { + type = string + default = "library-admin" + description = "Keycloak realm role name that grants admin in this app" +} + variable "secrets_kms_key_arn" { type = string description = "KMS key ARN that encrypts the Secrets Manager secrets" diff --git a/terraform/main/modules/secrets/main.tf b/terraform/main/modules/secrets/main.tf index 0167392..2f4735f 100644 --- a/terraform/main/modules/secrets/main.tf +++ b/terraform/main/modules/secrets/main.tf @@ -55,6 +55,28 @@ resource "aws_secretsmanager_secret_version" "flask" { }) } +# ── Keycloak / OIDC client secret ────────────────────────── +# Terraform creates the secret resource so IAM and ECS can reference its +# ARN; the actual `client_secret` value is rotated in out-of-band by +# whoever owns the keycloak-config repo. ignore_changes prevents this code +# from clobbering the rotated value on every apply. +resource "aws_secretsmanager_secret" "oidc" { + name = "${var.name}-${var.env}/oidc" + recovery_window_in_days = 0 + kms_key_id = aws_kms_key.secrets.arn +} + +resource "aws_secretsmanager_secret_version" "oidc" { + secret_id = aws_secretsmanager_secret.oidc.id + secret_string = jsonencode({ + client_secret = "PLACEHOLDER-rotate-from-keycloak-config-repo" + }) + + lifecycle { + ignore_changes = [secret_string] + } +} + locals { db_username = "librarian" } @@ -79,6 +101,10 @@ output "flask_secret_arn" { value = aws_secretsmanager_secret.flask.arn } +output "oidc_secret_arn" { + value = aws_secretsmanager_secret.oidc.arn +} + output "secrets_kms_key_arn" { value = aws_kms_key.secrets.arn } diff --git a/terraform/main/variables.tf b/terraform/main/variables.tf index 73deb73..9d3744d 100644 --- a/terraform/main/variables.tf +++ b/terraform/main/variables.tf @@ -50,3 +50,26 @@ variable "price_class" { default = "PriceClass_100" description = "CloudFront price class (PriceClass_100 = US/EU, PriceClass_200 = US/EU/Asia, PriceClass_All = all)" } + +# --- Keycloak / OIDC --- +# +# `oidc_issuer_url` is the realm root, e.g. https://login.keyholding.com/realms/keyholding. +# `oidc_client_id` is the confidential client registered in Keycloak; the +# matching client_secret lives in Secrets Manager (see modules/secrets/main.tf, +# the `oidc` resource — populated out-of-band by the keycloak-config repo). + +variable "oidc_issuer_url" { + type = string + description = "Keycloak realm root URL (issuer). Authlib appends /.well-known/openid-configuration." +} + +variable "oidc_client_id" { + type = string + description = "Keycloak OIDC client_id for this app" +} + +variable "oidc_admin_role" { + type = string + default = "library-admin" + description = "Keycloak realm role name that grants admin in this app" +}
{{ user.id }} {{ user.username }} {{ user.email }} -
- - -
-
{{ user.role }} {{ user.created_at.strftime('%Y-%m-%d') }} -
- - -
-
- + +