Skip to content

feat: switch authentication to Keycloak (OIDC)#4

Merged
dosaki merged 1 commit into
mainfrom
feat/keycloak-auth
May 8, 2026
Merged

feat: switch authentication to Keycloak (OIDC)#4
dosaki merged 1 commit into
mainfrom
feat/keycloak-auth

Conversation

@dosaki

@dosaki dosaki commented May 8, 2026

Copy link
Copy Markdown
Member

Summary

  • Replace local username/password login with OIDC Authorization Code + PKCE against login.keyholding.com so identity, password reset, and role assignment are managed centrally instead of in this app.
  • Add users.keycloak_sub (unique, indexed) and relax password_hash to nullable. The existing seeded admin links by email on first sign-in.
  • Drop signup, admin password-reset, and admin role-change UI — Keycloak owns these now. Local user delete kept as "delete the mirror row".
  • Wire OIDC envs through Terraform: OIDC_ISSUER_URL / OIDC_CLIENT_ID as plain envs, OIDC_CLIENT_SECRET from a new Secrets Manager secret (rotated out-of-band by the keycloak-config repo).
  • Add a local Keycloak service to docker-compose.yml for dev parity.

Notable decisions

  • Linking on sub (with email fallback) rather than email or username so admin renames in Keycloak don't break the link.
  • Realm roles read from the userinfo response, not the access token, so the app stays IdP-agnostic. Requires a "User Realm Role" mapper on the Keycloak client with Add to userinfo = ON (handled in the keycloak-config session).
  • password_hash kept on the model as nullable rather than dropped. Splitting "stop using" from "remove" lets us roll back without data loss.
  • New oidc Secrets Manager resource uses lifecycle.ignore_changes = [secret_string] so Terraform owns the resource shell while the keycloak-config side rotates the value.

Deploy sequence (after merge)

  • `terraform apply` in `terraform/main/envs/services` — creates `librarian-services/oidc` with a placeholder + IAM linkage.
  • Rotate the real client_secret into Secrets Manager:
    `aws secretsmanager put-secret-value --region eu-west-1 --secret-id librarian-services/oidc --secret-string '{"client_secret":"…"}'`
  • `aws ecs update-service --region eu-west-1 --cluster librarian-services --service librarian-services-api --force-new-deployment` so containers re-fetch the secret.
  • Confirm realm name (`oidc_issuer_url` in `envs/services/main.tf`) matches what keycloak-config provisioned.
  • Assign `library-admin` realm role to whichever Keycloak user(s) need admin (seeded `admin@tkc-library.local` row links by email on first OIDC login).

Test plan

  • Local: `docker compose up`, drop a realm export into `keycloak-import/`, hit `http://localhost:5000/auth/login\` → bounced to Keycloak → land on library index.
  • Verify `userinfo.realm_access.roles` includes `library-admin` for an admin test user (`curl -H "Authorization: Bearer " .../userinfo`).
  • Confirm `d1f5a2b9c0e4_add_keycloak_sub_to_users` runs cleanly on a copy of prod data; check the seeded admin row gets linked by email on its first OIDC sign-in.
  • Smoke test borrower flow: borrow / favourite / rate / unfavourite.
  • Smoke test admin flow: admin dashboard, book CRUD, "delete local mirror row" on the users page.
  • Logout returns to the Keycloak login screen and a second login does not silently re-authenticate (RP-initiated logout via `id_token_hint`).

🤖 Generated with Claude Code

Replace local username/password login with OIDC Authorization Code +
PKCE against login.keyholding.com so identity (creation, password
reset, role assignment) is managed centrally instead of in this app.

Local `users` rows now key on the Keycloak `sub` claim; the existing
seeded admin links by email on first sign-in. Signup, admin password
reset, and admin role-change UI removed — Keycloak owns these now.
Realm role `library-admin` maps to app-side admin via a userinfo
mapper on the Keycloak client.

Matching Keycloak realm/client config is provisioned out-of-band by the
keycloak-config repo. After deploy, rotate the client secret into
librarian-services/oidc and force a new ECS deployment so the new
task picks it up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings May 8, 2026 20:15
@github-actions

github-actions Bot commented May 8, 2026

Copy link
Copy Markdown

✅ Terraform Plan

Show plan
module.app.module.secrets.random_password.flask_secret_key: Refreshing state... [id=none]
module.app.module.secrets.random_password.rds: Refreshing state... [id=none]
module.app.module.cloudfront.aws_acm_certificate.this: Refreshing state... [id=arn:aws:acm:us-east-1:287917017203:certificate/ec77dd32-85ea-4c4d-9c57-44276512b85e]
module.app.data.aws_region.current: Reading...
module.app.module.vpc.data.aws_ec2_managed_prefix_list.cloudfront: Reading...
module.app.module.ecs.aws_iam_role.ecs_execution: Refreshing state... [id=librarian-services-ecs-execution]
module.app.module.ecs.aws_iam_role.ecs_task: Refreshing state... [id=librarian-services-ecs-task]
module.app.data.aws_route53_zone.this: Reading...
module.app.module.ecs.aws_cloudwatch_log_group.api: Refreshing state... [id=/ecs/librarian-services/api]
module.app.module.ecr.aws_ecr_repository.api: Refreshing state... [id=librarian-services-api]
module.app.module.vpc.aws_vpc.this: Refreshing state... [id=vpc-02acfa95215273b1a]
module.app.data.aws_region.current: Read complete after 0s [id=eu-west-1]
module.app.module.s3_uploads.aws_s3_bucket.this: Refreshing state... [id=tkc-librarian-uploads-services]
module.app.module.ecs.aws_ecs_cluster.this: Refreshing state... [id=arn:aws:ecs:eu-west-1:287917017203:cluster/librarian-services]
module.app.module.secrets.aws_kms_key.secrets: Refreshing state... [id=5082d3c6-6e61-4214-953d-9eff08efbe26]
module.app.module.rds.aws_kms_key.rds: Refreshing state... [id=f22fab6b-f703-4b2f-8954-35c4a29f8cca]
module.app.module.ecs.aws_iam_role_policy_attachment.ecs_execution: Refreshing state... [id=librarian-services-ecs-execution/arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy]
module.app.data.aws_route53_zone.this: Read complete after 1s [id=Z01465943T0FH4FSY39YX]
module.app.module.cloudfront.aws_route53_record.cert_validation["librarian.services.keyholding.com"]: Refreshing state... [id=Z01465943T0FH4FSY39YX__fc428249a28c1ad1ab6c0bd84b466f71.librarian.services.keyholding.com._CNAME]
module.app.module.ecr.aws_ecr_lifecycle_policy.api: Refreshing state... [id=librarian-services-api]
module.app.module.cloudfront.aws_acm_certificate_validation.this: Refreshing state... [id=2026-05-01 07:16:18.356 +0000 UTC]
module.app.module.vpc.data.aws_ec2_managed_prefix_list.cloudfront: Read complete after 1s [id=pl-4fa04526]
module.app.module.secrets.aws_kms_alias.secrets: Refreshing state... [id=alias/librarian-services-secrets]
module.app.module.secrets.aws_secretsmanager_secret.flask: Refreshing state... [id=arn:aws:secretsmanager:eu-west-1:287917017203:secret:librarian-services/flask-hRr9DH]
module.app.module.secrets.aws_secretsmanager_secret.rds: Refreshing state... [id=arn:aws:secretsmanager:eu-west-1:287917017203:secret:librarian-services/rds-master-1Pe4QF]
module.app.module.rds.aws_kms_alias.rds: Refreshing state... [id=alias/librarian-services-rds]
module.app.module.secrets.aws_secretsmanager_secret_version.flask: Refreshing state... [id=arn:aws:secretsmanager:eu-west-1:287917017203:secret:librarian-services/flask-hRr9DH|terraform-20260501071611668800000005]
module.app.module.secrets.aws_secretsmanager_secret_version.rds: Refreshing state... [id=arn:aws:secretsmanager:eu-west-1:287917017203:secret:librarian-services/rds-master-1Pe4QF|terraform-20260501071611658500000004]
module.app.module.ecs.aws_iam_role_policy.ecs_secrets: Refreshing state... [id=librarian-services-ecs-execution:librarian-services-ecs-secrets]
module.app.module.vpc.aws_internet_gateway.this: Refreshing state... [id=igw-0884aa16999920657]
module.app.module.vpc.aws_route_table.public: Refreshing state... [id=rtb-07a1aa8f490842628]
module.app.module.vpc.aws_subnet.public[1]: Refreshing state... [id=subnet-01ac333871ebe6e5a]
module.app.module.vpc.aws_subnet.private[1]: Refreshing state... [id=subnet-09de5647eae05a3df]
module.app.module.vpc.aws_subnet.public[0]: Refreshing state... [id=subnet-00169ab5413130ed8]
module.app.module.vpc.aws_security_group.alb: Refreshing state... [id=sg-0b1fa429b4f2f3e2e]
module.app.module.alb.aws_lb_target_group.api: Refreshing state... [id=arn:aws:elasticloadbalancing:eu-west-1:287917017203:targetgroup/librarian-services-api/c101a5766af84f32]
module.app.module.vpc.aws_subnet.private[0]: Refreshing state... [id=subnet-0e3605c6355bd18df]
module.app.module.vpc.aws_route.public_internet: Refreshing state... [id=r-rtb-07a1aa8f4908426281080289494]
module.app.module.vpc.aws_route_table_association.public[0]: Refreshing state... [id=rtbassoc-0ad19c3756bd97dfe]
module.app.module.vpc.aws_route_table_association.public[1]: Refreshing state... [id=rtbassoc-0fc8cdac0f4250a1d]
module.app.module.vpc.aws_security_group.ecs: Refreshing state... [id=sg-0a0511e4b803ab1f5]
module.app.module.alb.aws_lb.this: Refreshing state... [id=arn:aws:elasticloadbalancing:eu-west-1:287917017203:loadbalancer/app/librarian-services/0f38a5e21d340de7]
module.app.module.rds.aws_db_subnet_group.this: Refreshing state... [id=librarian-services]
module.app.module.s3_uploads.data.aws_iam_policy_document.put_object: Reading...
module.app.module.s3_uploads.aws_s3_bucket_server_side_encryption_configuration.this: Refreshing state... [id=tkc-librarian-uploads-services]
module.app.module.s3_uploads.data.aws_iam_policy_document.public_read: Reading...
module.app.module.s3_uploads.aws_s3_bucket_public_access_block.this: Refreshing state... [id=tkc-librarian-uploads-services]
module.app.module.s3_uploads.data.aws_iam_policy_document.put_object: Read complete after 0s [id=732345744]
module.app.module.s3_uploads.data.aws_iam_policy_document.public_read: Read complete after 0s [id=3797438981]
module.app.module.s3_uploads.aws_s3_bucket_lifecycle_configuration.this: Refreshing state... [id=tkc-librarian-uploads-services]
module.app.module.s3_uploads.aws_s3_bucket_cors_configuration.this: Refreshing state... [id=tkc-librarian-uploads-services]
module.app.module.s3_uploads.aws_iam_policy.put_object: Refreshing state... [id=arn:aws:iam::287917017203:policy/librarian-services-s3-uploads-put]
module.app.module.vpc.aws_security_group.rds: Refreshing state... [id=sg-0ae84cafda9660d19]
module.app.aws_iam_role_policy_attachment.ecs_task_s3_uploads: Refreshing state... [id=librarian-services-ecs-task/arn:aws:iam::287917017203:policy/librarian-services-s3-uploads-put]
module.app.module.s3_uploads.aws_s3_bucket_policy.this: Refreshing state... [id=tkc-librarian-uploads-services]
module.app.module.alb.aws_lb_listener.http: Refreshing state... [id=arn:aws:elasticloadbalancing:eu-west-1:287917017203:listener/app/librarian-services/0f38a5e21d340de7/e53d113a9ffbcdca]
module.app.module.cloudfront.aws_cloudfront_distribution.this: Refreshing state... [id=E112G77GVDUPHT]
module.app.module.rds.aws_db_instance.this: Refreshing state... [id=db-WDSBTCMQJ6DWQUDCPK3GM7EDIA]
module.app.module.cloudfront.aws_route53_record.a: Refreshing state... [id=Z01465943T0FH4FSY39YX_librarian.services.keyholding.com_A]
module.app.module.cloudfront.aws_route53_record.aaaa: Refreshing state... [id=Z01465943T0FH4FSY39YX_librarian.services.keyholding.com_AAAA]
module.app.module.ecs.aws_ecs_task_definition.api: Refreshing state... [id=librarian-services-api]
module.app.module.ecs.aws_ecs_service.api: Refreshing state... [id=arn:aws:ecs:eu-west-1:287917017203:service/librarian-services/librarian-services-api]

Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
  + create
  ~ update in-place
-/+ destroy and then create replacement

Terraform will perform the following actions:

  # module.app.module.ecs.aws_ecs_service.api will be updated in-place
  ~ resource "aws_ecs_service" "api" {
        id                                 = "arn:aws:ecs:eu-west-1:287917017203:service/librarian-services/librarian-services-api"
        name                               = "librarian-services-api"
        tags                               = {}
      ~ task_definition                    = "arn:aws:ecs:eu-west-1:287917017203:task-definition/librarian-services-api:2" -> (known after apply)
        # (18 unchanged attributes hidden)

        # (5 unchanged blocks hidden)
    }

  # module.app.module.ecs.aws_ecs_task_definition.api must be replaced
-/+ resource "aws_ecs_task_definition" "api" {
      ~ arn                      = "arn:aws:ecs:eu-west-1:287917017203:task-definition/librarian-services-api:2" -> (known after apply)
      ~ arn_without_revision     = "arn:aws:ecs:eu-west-1:287917017203:task-definition/librarian-services-api" -> (known after apply)
      ~ container_definitions    = jsonencode(
            [
              - {
                  - environment      = [
                      - {
                          - name  = "AWS_REGION"
                          - value = "eu-west-1"
                        },
                      - {
                          - name  = "DB_HOST"
                          - value = "librarian-services.cfqq2pixhtkl.eu-west-1.rds.amazonaws.com"
                        },
                      - {
                          - name  = "DB_NAME"
                          - value = "librarian"
                        },
                      - {
                          - name  = "DB_PORT"
                          - value = "5432"
                        },
                      - {
                          - name  = "DB_USER"
                          - value = "librarian"
                        },
                      - {
                          - name  = "HOST"
                          - value = "0.0.0.0"
                        },
                      - {
                          - name  = "PORT"
                          - value = "8080"
                        },
                      - {
                          - name  = "S3_BUCKET"
                          - value = "tkc-librarian-uploads-services"
                        },
                      - {
                          - name  = "S3_PUBLIC_BASE_URL"
                          - value = "https://tkc-librarian-uploads-services.s3.eu-west-1.amazonaws.com"
                        },
                    ]
                  - essential        = true
                  - image            = "287917017203.dkr.ecr.eu-west-1.amazonaws.com/librarian-services-api:latest"
                  - logConfiguration = {
                      - logDriver = "awslogs"
                      - options   = {
                          - awslogs-group         = "/ecs/librarian-services/api"
                          - awslogs-region        = "eu-west-1"
                          - awslogs-stream-prefix = "api"
                        }
                    }
                  - mountPoints      = []
                  - name             = "api"
                  - portMappings     = [
                      - {
                          - containerPort = 8080
                          - hostPort      = 8080
                          - protocol      = "tcp"
                        },
                    ]
                  - secrets          = [
                      - {
                          - name      = "DB_PASSWORD"
                          - valueFrom = "arn:aws:secretsmanager:eu-west-1:287917017203:secret:librarian-services/rds-master-1Pe4QF:password::"
                        },
                      - {
                          - name      = "SECRET_KEY"
                          - valueFrom = "arn:aws:secretsmanager:eu-west-1:287917017203:secret:librarian-services/flask-hRr9DH:secret_key::"
                        },
                    ]
                  - systemControls   = []
                  - volumesFrom      = []
                },
            ]
        ) -> (known after apply) # forces replacement
      ~ enable_fault_injection   = false -> (known after apply)
      ~ id                       = "librarian-services-api" -> (known after apply)
      ~ revision                 = 2 -> (known after apply)
      - tags                     = {} -> null
        # (13 unchanged attributes hidden)
    }

  # module.app.module.ecs.aws_iam_role_policy.ecs_secrets will be updated in-place
  ~ resource "aws_iam_role_policy" "ecs_secrets" {
        id          = "librarian-services-ecs-execution:librarian-services-ecs-secrets"
        name        = "librarian-services-ecs-secrets"
      ~ policy      = jsonencode(
            {
              - Statement = [
                  - {
                      - Action   = [
                          - "secretsmanager:GetSecretValue",
                        ]
                      - Effect   = "Allow"
                      - Resource = [
                          - "arn:aws:secretsmanager:eu-west-1:287917017203:secret:librarian-services/rds-master-1Pe4QF",
                          - "arn:aws:secretsmanager:eu-west-1:287917017203:secret:librarian-services/flask-hRr9DH",
                        ]
                    },
                  - {
                      - Action   = [
                          - "kms:Decrypt",
                        ]
                      - Effect   = "Allow"
                      - Resource = "arn:aws:kms:eu-west-1:287917017203:key/5082d3c6-6e61-4214-953d-9eff08efbe26"
                    },
                ]
              - Version   = "2012-10-17"
            }
        ) -> (known after apply)
        # (2 unchanged attributes hidden)
    }

  # module.app.module.secrets.aws_secretsmanager_secret.oidc will be created
  + resource "aws_secretsmanager_secret" "oidc" {
      + arn                            = (known after apply)
      + force_overwrite_replica_secret = false
      + id                             = (known after apply)
      + kms_key_id                     = "arn:aws:kms:eu-west-1:287917017203:key/5082d3c6-6e61-4214-953d-9eff08efbe26"
      + name                           = "librarian-services/oidc"
      + name_prefix                    = (known after apply)
      + policy                         = (known after apply)
      + recovery_window_in_days        = 0
      + region                         = "eu-west-1"
      + tags_all                       = {
          + "Area"        = "Internal"
          + "Environment" = "services"
          + "ManagedBy"   = "terraform"
          + "Project"     = "librarian"
          + "SubArea"     = "Tech Enablers"
          + "Team"        = "Application Development"
        }

      + replica (known after apply)
    }

  # module.app.module.secrets.aws_secretsmanager_secret_version.oidc will be created
  + resource "aws_secretsmanager_secret_version" "oidc" {
      + arn                  = (known after apply)
      + has_secret_string_wo = (known after apply)
      + id                   = (known after apply)
      + region               = "eu-west-1"
      + secret_id            = (known after apply)
      + secret_string        = (sensitive value)
      + secret_string_wo     = (write-only attribute)
      + version_id           = (known after apply)
      + version_stages       = (known after apply)
    }

Plan: 3 to add, 2 to change, 1 to destroy.

─────────────────────────────────────────────────────────────────────────────

Note: You didn't use the -out option to save this plan, so Terraform can't
guarantee to take exactly these actions if you run "terraform apply" now.
Releasing state lock. This may take a few moments...

@dosaki dosaki merged commit a0f02d5 into main May 8, 2026
3 checks passed

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR switches the application’s authentication from local username/password to Keycloak-backed OIDC Authorization Code + PKCE, aligning identity, password resets, and role assignment with a centralized IdP while keeping a local “mirror” user row for app-specific data.

Changes:

  • Introduces OIDC configuration in the Flask app (Authlib client, login/callback/logout flow) and removes local login/signup flows.
  • Updates the data model/migrations to support Keycloak linkage (users.keycloak_sub) and makes password_hash nullable.
  • Wires OIDC settings into Terraform/ECS and adds a local Keycloak service for development.

Reviewed changes

Copilot reviewed 20 out of 21 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
terraform/main/variables.tf Adds OIDC-related Terraform variables (issuer, client_id, admin role).
terraform/main/modules/secrets/main.tf Adds a Secrets Manager secret for the OIDC client secret and outputs its ARN.
terraform/main/modules/ecs/variables.tf Adds ECS module inputs for OIDC env vars + secret ARN.
terraform/main/modules/ecs/main.tf Injects OIDC env vars and OIDC client secret into the ECS task definition.
terraform/main/main.tf Wires OIDC variables and secret ARN through to the ECS module.
terraform/main/envs/services/main.tf Sets service environment OIDC issuer URL and client_id.
requirements.txt Adds Authlib dependency for OIDC integration.
migrations/versions/d1f5a2b9c0e4_add_keycloak_sub_to_users.py Adds keycloak_sub, makes password_hash nullable, and adds uniqueness/indexing.
docker-compose.yml Adds a local Keycloak service (dev mode) with realm import volume.
config.py Adds OIDC config keys sourced from environment variables.
app/models.py Adds User.keycloak_sub and makes password_hash nullable.
app/library/routes.py Removes “create user on the fly” behavior; uses session user_id to resolve the current user.
app/forms.py Removes the signup form definitions and related validators/imports.
app/extensions.py Adds an Authlib OAuth extension instance.
app/auth/templates/auth/signup.html Removes legacy signup page template.
app/auth/templates/auth/login.html Removes legacy login page template.
app/auth/routes.py Replaces local login/signup with OIDC login, callback, logout, and user upsert logic.
app/admin/templates/admin/users.html Removes role/password management UI; clarifies Keycloak ownership and keeps “delete local row”.
app/admin/templates/admin/dashboard.html Updates dashboard copy to reflect Keycloak-managed identity/roles/passwords.
app/admin/routes.py Removes reset-password and role-change endpoints; keeps delete behavior with updated messaging.
app/init.py Registers Authlib OIDC client during app creation.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread app/__init__.py
Comment on lines +17 to +22
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"],
Comment thread app/auth/routes.py
@bp.route("/callback")
def callback():
token = oauth.keycloak.authorize_access_token()
claims = token.get("userinfo") or {}
Comment thread app/auth/routes.py
Comment on lines +68 to +70
email = claims.get("email") or f"{sub}@unknown.local"
username = claims.get("preferred_username") or email

Comment on lines +26 to +30
op.create_index("ix_users_keycloak_sub", "users", ["keycloak_sub"])


def downgrade():
op.drop_index("ix_users_keycloak_sub", table_name="users")
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")
Comment thread app/models.py
# 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)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants