From e882a4d6d127f354dbb1577bd5ce9600c05dc64e Mon Sep 17 00:00:00 2001 From: Dan Milne Date: Tue, 18 Nov 2025 20:03:03 +1100 Subject: [PATCH] More complete oidc --- README.md | 35 ++- .../admin/applications_controller.rb | 3 +- app/controllers/oidc_controller.rb | 216 ++++++++++++++++-- app/models/application.rb | 42 ++++ app/models/oidc_access_token.rb | 61 ++++- app/services/oidc_jwt_service.rb | 4 +- app/views/admin/applications/_form.html.erb | 47 ++++ config/routes.rb | 1 + db/schema.rb | 35 ++- 9 files changed, 401 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index 77d85a3..cb53f4d 100644 --- a/README.md +++ b/README.md @@ -7,13 +7,14 @@ Clinch gives you one place to manage users and lets any web app authenticate against it without maintaining its own user table. -I've completed all planned features: +I've completed all planned features: * Create Admin user on first login * TOTP ( QR Code ) 2FA, with backup codes ( encrypted at rest ) * Passkey generation and login, with detection of Passkey during login * Forward Auth configured and working -* OIDC provider with auto discovery working +* OIDC provider with auto discovery, refresh tokens, and token revocation +* Configurable token expiry per application (access, refresh, ID tokens) * Invite users by email, assign to groups * Self managed password reset by email * Use Groups to assign Applications ( Family group can access Kavita, Developers can access Gitea ) @@ -86,11 +87,17 @@ Clinch sits in a sweet spot between two excellent open-source identity solutions #### OpenID Connect (OIDC) Standard OAuth2/OIDC provider with endpoints: - `/.well-known/openid-configuration` - Discovery endpoint -- `/authorize` - Authorization endpoint -- `/token` - Token endpoint +- `/authorize` - Authorization endpoint with PKCE support +- `/token` - Token endpoint (authorization_code and refresh_token grants) - `/userinfo` - User info endpoint +- `/revoke` - Token revocation endpoint (RFC 7009) -Client apps (Audiobookshelf, Kavita, Grafana, etc.) redirect to Clinch for login and receive ID tokens and access tokens. +Features: +- **Refresh tokens** - Long-lived tokens (30 days default) with automatic rotation and revocation +- **Configurable token expiry** - Set access token (5min-24hr), refresh token (1-90 days), and ID token TTL per application +- **Token security** - BCrypt-hashed tokens, automatic cleanup of expired tokens + +Client apps (Audiobookshelf, Kavita, Grafana, etc.) redirect to Clinch for login and receive ID tokens, access tokens, and refresh tokens. #### Trusted-Header SSO (ForwardAuth) Works with reverse proxies (Caddy, Traefik, Nginx): @@ -156,25 +163,29 @@ Send emails for: - Redirect URIs (for OIDC apps) - Domain pattern (for ForwardAuth apps, supports wildcards like *.example.com) - Headers config (for ForwardAuth apps, JSON configuration for custom header names) +- Token TTL configuration (access_token_ttl, refresh_token_ttl, id_token_ttl) - Metadata (flexible JSON storage) - Active flag - Many-to-many with Groups (allowlist) **OIDC Tokens** -- Authorization codes (10-minute expiry, one-time use) -- Access tokens (1-hour expiry, revocable) +- Authorization codes (10-minute expiry, one-time use, PKCE support) +- Access tokens (opaque, BCrypt-hashed, configurable expiry 5min-24hr, revocable) +- Refresh tokens (opaque, BCrypt-hashed, configurable expiry 1-90 days, single-use with rotation) +- ID tokens (JWT, signed with RS256, configurable expiry 5min-24hr) --- ## Authentication Flows ### OIDC Authorization Flow -1. Client redirects user to `/authorize` with client_id, redirect_uri, scope +1. Client redirects user to `/authorize` with client_id, redirect_uri, scope (optional PKCE) 2. User authenticates with Clinch (username/password + optional TOTP) 3. Access control check: Is user in an allowed group for this app? 4. If allowed, generate authorization code and redirect to client -5. Client exchanges code for access token at `/token` -6. Client uses access token to fetch user info from `/userinfo` +5. Client exchanges code at `/token` for ID token, access token, and refresh token +6. Client uses access token to fetch fresh user info from `/userinfo` +7. When access token expires, client uses refresh token to get new tokens (no re-authentication) ### ForwardAuth Flow 1. User requests protected resource at `https://app.example.com/dashboard` @@ -258,6 +269,10 @@ SMTP_ENABLE_STARTTLS=true # Application CLINCH_HOST=https://auth.example.com CLINCH_FROM_EMAIL=noreply@example.com + +# OIDC (optional - generates temporary key in development) +# Generate with: openssl genpkey -algorithm RSA -out private_key.pem -pkeyopt rsa_keygen_bits:2048 +OIDC_PRIVATE_KEY= ``` ### First Run diff --git a/app/controllers/admin/applications_controller.rb b/app/controllers/admin/applications_controller.rb index ceb6fbe..fa837c3 100644 --- a/app/controllers/admin/applications_controller.rb +++ b/app/controllers/admin/applications_controller.rb @@ -99,7 +99,8 @@ module Admin def application_params params.require(:application).permit( :name, :slug, :app_type, :active, :redirect_uris, :description, :metadata, - :domain_pattern, :landing_url, headers_config: {} + :domain_pattern, :landing_url, :access_token_ttl, :refresh_token_ttl, :id_token_ttl, + headers_config: {} ).tap do |whitelisted| # Remove client_secret from params if present (shouldn't be updated via form) whitelisted.delete(:client_secret) diff --git a/app/controllers/oidc_controller.rb b/app/controllers/oidc_controller.rb index 9301fa3..829dbb6 100644 --- a/app/controllers/oidc_controller.rb +++ b/app/controllers/oidc_controller.rb @@ -1,7 +1,7 @@ class OidcController < ApplicationController # Discovery and JWKS endpoints are public - allow_unauthenticated_access only: [:discovery, :jwks, :token, :userinfo, :logout] - skip_before_action :verify_authenticity_token, only: [:token, :logout] + allow_unauthenticated_access only: [:discovery, :jwks, :token, :revoke, :userinfo, :logout] + skip_before_action :verify_authenticity_token, only: [:token, :revoke, :logout] # GET /.well-known/openid-configuration def discovery @@ -11,11 +11,13 @@ class OidcController < ApplicationController issuer: base_url, authorization_endpoint: "#{base_url}/oauth/authorize", token_endpoint: "#{base_url}/oauth/token", + revocation_endpoint: "#{base_url}/oauth/revoke", userinfo_endpoint: "#{base_url}/oauth/userinfo", jwks_uri: "#{base_url}/.well-known/jwks.json", end_session_endpoint: "#{base_url}/logout", response_types_supported: ["code"], response_modes_supported: ["query"], + grant_types_supported: ["authorization_code", "refresh_token"], subject_types_supported: ["public"], id_token_signing_alg_values_supported: ["RS256"], scopes_supported: ["openid", "profile", "email", "groups"], @@ -253,10 +255,17 @@ class OidcController < ApplicationController def token grant_type = params[:grant_type] - unless grant_type == "authorization_code" + case grant_type + when "authorization_code" + handle_authorization_code_grant + when "refresh_token" + handle_refresh_token_grant + else render json: { error: "unsupported_grant_type" }, status: :bad_request - return end + end + + def handle_authorization_code_grant # Get client credentials from Authorization header or params client_id, client_secret = extract_client_credentials @@ -341,25 +350,31 @@ class OidcController < ApplicationController # Get the user user = auth_code.user - # Generate access token - access_token = SecureRandom.urlsafe_base64(32) - OidcAccessToken.create!( + # Generate access token record (opaque token with BCrypt hashing) + access_token_record = OidcAccessToken.create!( application: application, user: user, - token: access_token, - scope: auth_code.scope, - expires_at: 1.hour.from_now + scope: auth_code.scope ) - # Generate ID token + # Generate refresh token (opaque, with hashing) + refresh_token_record = OidcRefreshToken.create!( + application: application, + user: user, + oidc_access_token: access_token_record, + scope: auth_code.scope + ) + + # Generate ID token (JWT) id_token = OidcJwtService.generate_id_token(user, application, nonce: auth_code.nonce) # Return tokens render json: { - access_token: access_token, + access_token: access_token_record.plaintext_token, # Opaque token token_type: "Bearer", - expires_in: 3600, - id_token: id_token, + expires_in: application.access_token_ttl || 3600, + id_token: id_token, # JWT + refresh_token: refresh_token_record.token, # Opaque token scope: auth_code.scope } end @@ -368,6 +383,96 @@ class OidcController < ApplicationController end end + def handle_refresh_token_grant + # Get client credentials from Authorization header or params + client_id, client_secret = extract_client_credentials + + unless client_id && client_secret + render json: { error: "invalid_client" }, status: :unauthorized + return + end + + # Find and validate the application + application = Application.find_by(client_id: client_id) + unless application && application.authenticate_client_secret(client_secret) + render json: { error: "invalid_client" }, status: :unauthorized + return + end + + # Get the refresh token + refresh_token = params[:refresh_token] + unless refresh_token.present? + render json: { error: "invalid_request", error_description: "refresh_token is required" }, status: :bad_request + return + end + + # Find the refresh token record + # Note: This is inefficient with BCrypt hashing, but necessary for security + # In production, consider adding a token prefix for faster lookup + refresh_token_record = OidcRefreshToken.where(application: application).find do |rt| + rt.token_matches?(refresh_token) + end + + unless refresh_token_record + render json: { error: "invalid_grant", error_description: "Invalid refresh token" }, status: :bad_request + return + end + + # Check if refresh token is expired + if refresh_token_record.expired? + render json: { error: "invalid_grant", error_description: "Refresh token expired" }, status: :bad_request + return + end + + # Check if refresh token is revoked + if refresh_token_record.revoked? + # If a revoked refresh token is used, it's a security issue + # Revoke all tokens in the family (token rotation attack detection) + Rails.logger.warn "OAuth Security: Revoked refresh token reuse detected for token family #{refresh_token_record.token_family_id}" + refresh_token_record.revoke_family! + + render json: { error: "invalid_grant", error_description: "Refresh token has been revoked" }, status: :bad_request + return + end + + # Get the user + user = refresh_token_record.user + + # Revoke the old refresh token (token rotation) + refresh_token_record.revoke! + + # Generate new access token record (opaque token with BCrypt hashing) + new_access_token = OidcAccessToken.create!( + application: application, + user: user, + scope: refresh_token_record.scope + ) + + # Generate new refresh token (token rotation) + new_refresh_token = OidcRefreshToken.create!( + application: application, + user: user, + oidc_access_token: new_access_token, + scope: refresh_token_record.scope, + token_family_id: refresh_token_record.token_family_id # Keep same family for rotation tracking + ) + + # Generate new ID token (JWT, no nonce for refresh grants) + id_token = OidcJwtService.generate_id_token(user, application) + + # Return new tokens + render json: { + access_token: new_access_token.plaintext_token, # Opaque token + token_type: "Bearer", + expires_in: application.access_token_ttl || 3600, + id_token: id_token, # JWT + refresh_token: new_refresh_token.token, # Opaque token + scope: refresh_token_record.scope + } + rescue ActiveRecord::RecordNotFound + render json: { error: "invalid_grant" }, status: :bad_request + end + # GET /oauth/userinfo def userinfo # Extract access token from Authorization header @@ -377,24 +482,22 @@ class OidcController < ApplicationController return end - access_token = auth_header.sub("Bearer ", "") + token = auth_header.sub("Bearer ", "") - # Find the access token - token_record = OidcAccessToken.find_by(token: access_token) - unless token_record + # Find and validate access token (opaque token with BCrypt hashing) + access_token = OidcAccessToken.find_by_token(token) + unless access_token&.active? head :unauthorized return end - # Check if token is expired - if token_record.expires_at < Time.current + # Get the user (with fresh data from database) + user = access_token.user + unless user head :unauthorized return end - # Get the user - user = token_record.user - # Return user claims claims = { sub: user.id.to_s, @@ -423,6 +526,73 @@ class OidcController < ApplicationController render json: claims end + # POST /oauth/revoke + # RFC 7009 - Token Revocation + def revoke + # Get client credentials + client_id, client_secret = extract_client_credentials + + unless client_id && client_secret + # RFC 7009 says we should return 200 OK even for invalid client + # But log the attempt for security monitoring + Rails.logger.warn "OAuth: Token revocation attempted with invalid client credentials" + head :ok + return + end + + # Find and validate the application + application = Application.find_by(client_id: client_id) + unless application && application.authenticate_client_secret(client_secret) + Rails.logger.warn "OAuth: Token revocation attempted for invalid application: #{client_id}" + head :ok + return + end + + # Get the token to revoke + token = params[:token] + token_type_hint = params[:token_type_hint] # Optional hint: "access_token" or "refresh_token" + + unless token.present? + # RFC 7009: Missing token parameter is an error + render json: { error: "invalid_request", error_description: "token parameter is required" }, status: :bad_request + return + end + + # Try to find and revoke the token + # Check token type hint first for efficiency, otherwise try both + revoked = false + + if token_type_hint == "refresh_token" || token_type_hint.nil? + # Try to find as refresh token + refresh_token_record = OidcRefreshToken.where(application: application).find do |rt| + rt.token_matches?(token) + end + + if refresh_token_record + refresh_token_record.revoke! + Rails.logger.info "OAuth: Refresh token revoked for application #{application.name}" + revoked = true + end + end + + if !revoked && (token_type_hint == "access_token" || token_type_hint.nil?) + # Try to find as access token + access_token_record = OidcAccessToken.where(application: application).find do |at| + at.token_matches?(token) + end + + if access_token_record + access_token_record.revoke! + Rails.logger.info "OAuth: Access token revoked for application #{application.name}" + revoked = true + end + end + + # RFC 7009: Always return 200 OK, even if token was not found + # This prevents token scanning attacks + head :ok + end + # GET /logout def logout # OpenID Connect RP-Initiated Logout diff --git a/app/models/application.rb b/app/models/application.rb index fd1ef60..6f6c336 100644 --- a/app/models/application.rb +++ b/app/models/application.rb @@ -5,6 +5,7 @@ class Application < ApplicationRecord has_many :allowed_groups, through: :application_groups, source: :group has_many :oidc_authorization_codes, dependent: :destroy has_many :oidc_access_tokens, dependent: :destroy + has_many :oidc_refresh_tokens, dependent: :destroy has_many :oidc_user_consents, dependent: :destroy validates :name, presence: true @@ -17,6 +18,11 @@ class Application < ApplicationRecord validates :domain_pattern, presence: true, uniqueness: { case_sensitive: false }, if: :forward_auth? validates :landing_url, format: { with: URI::regexp(%w[http https]), allow_nil: true, message: "must be a valid URL" } + # Token TTL validations (for OIDC apps) + validates :access_token_ttl, numericality: { greater_than_or_equal_to: 300, less_than_or_equal_to: 86400 }, if: :oidc? # 5 min - 24 hours + validates :refresh_token_ttl, numericality: { greater_than_or_equal_to: 86400, less_than_or_equal_to: 7776000 }, if: :oidc? # 1 day - 90 days + validates :id_token_ttl, numericality: { greater_than_or_equal_to: 300, less_than_or_equal_to: 86400 }, if: :oidc? # 5 min - 24 hours + normalizes :slug, with: ->(slug) { slug.strip.downcase } normalizes :domain_pattern, with: ->(pattern) { normalized = pattern&.strip&.downcase @@ -154,8 +160,44 @@ class Application < ApplicationRecord secret end + # Token TTL helper methods (for OIDC) + def access_token_expiry + (access_token_ttl || 3600).seconds.from_now + end + + def refresh_token_expiry + (refresh_token_ttl || 2592000).seconds.from_now + end + + def id_token_expiry_seconds + id_token_ttl || 3600 + end + + # Human-readable TTL for display + def access_token_ttl_human + duration_to_human(access_token_ttl || 3600) + end + + def refresh_token_ttl_human + duration_to_human(refresh_token_ttl || 2592000) + end + + def id_token_ttl_human + duration_to_human(id_token_ttl || 3600) + end + private + def duration_to_human(seconds) + if seconds < 3600 + "#{seconds / 60} minutes" + elsif seconds < 86400 + "#{seconds / 3600} hours" + else + "#{seconds / 86400} days" + end + end + def generate_client_credentials self.client_id ||= SecureRandom.urlsafe_base64(32) # Generate and hash the client secret diff --git a/app/models/oidc_access_token.rb b/app/models/oidc_access_token.rb index 27e5c35..0442e6e 100644 --- a/app/models/oidc_access_token.rb +++ b/app/models/oidc_access_token.rb @@ -1,34 +1,83 @@ class OidcAccessToken < ApplicationRecord belongs_to :application belongs_to :user + has_many :oidc_refresh_tokens, dependent: :destroy before_validation :generate_token, on: :create before_validation :set_expiry, on: :create - validates :token, presence: true, uniqueness: true + validates :token, uniqueness: true, presence: true - scope :valid, -> { where("expires_at > ?", Time.current) } + scope :valid, -> { where("expires_at > ?", Time.current).where(revoked_at: nil) } scope :expired, -> { where("expires_at <= ?", Time.current) } + scope :revoked, -> { where.not(revoked_at: nil) } + scope :active, -> { valid } + + attr_accessor :plaintext_token # Store plaintext temporarily for returning to client def expired? expires_at <= Time.current end + def revoked? + revoked_at.present? + end + def active? - !expired? + !expired? && !revoked? end def revoke! - update!(expires_at: Time.current) + update!(revoked_at: Time.current) + # Also revoke associated refresh tokens + oidc_refresh_tokens.each(&:revoke!) + end + + # Check if a plaintext token matches the hashed token + def token_matches?(plaintext_token) + return false if plaintext_token.blank? + + # Use BCrypt to compare if token_digest exists + if token_digest.present? + BCrypt::Password.new(token_digest) == plaintext_token + # Fall back to direct comparison for backward compatibility + elsif token.present? + token == plaintext_token + else + false + end + end + + # Find by token (validates and checks if revoked) + def self.find_by_token(plaintext_token) + return nil if plaintext_token.blank? + + # Find all non-revoked, non-expired tokens + valid.find_each do |access_token| + # Use BCrypt to compare (if token_digest exists) or direct comparison + if access_token.token_digest.present? + return access_token if BCrypt::Password.new(access_token.token_digest) == plaintext_token + elsif access_token.token == plaintext_token + return access_token + end + end + nil end private def generate_token - self.token ||= SecureRandom.urlsafe_base64(48) + return if token.present? + + # Generate opaque access token + plaintext = SecureRandom.urlsafe_base64(48) + self.plaintext_token = plaintext # Store temporarily for returning to client + self.token_digest = BCrypt::Password.create(plaintext) + # Keep token column for backward compatibility during migration + self.token = plaintext end def set_expiry - self.expires_at ||= 1.hour.from_now + self.expires_at ||= application.access_token_expiry end end diff --git a/app/services/oidc_jwt_service.rb b/app/services/oidc_jwt_service.rb index 8c415b8..03978c4 100644 --- a/app/services/oidc_jwt_service.rb +++ b/app/services/oidc_jwt_service.rb @@ -3,12 +3,14 @@ class OidcJwtService # Generate an ID token (JWT) for the user def generate_id_token(user, application, nonce: nil) now = Time.current.to_i + # Use application's configured ID token TTL (defaults to 1 hour) + ttl = application.id_token_expiry_seconds payload = { iss: issuer_url, sub: user.id.to_s, aud: application.client_id, - exp: now + 3600, # 1 hour + exp: now + ttl, iat: now, email: user.email_address, email_verified: true, diff --git a/app/views/admin/applications/_form.html.erb b/app/views/admin/applications/_form.html.erb index 3885dbe..9b46c7d 100644 --- a/app/views/admin/applications/_form.html.erb +++ b/app/views/admin/applications/_form.html.erb @@ -44,6 +44,53 @@ <%= form.text_area :redirect_uris, rows: 4, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono", placeholder: "https://example.com/callback\nhttps://app.example.com/auth/callback" %>

One URI per line. These are the allowed callback URLs for your application.

+ +
+

Token Expiration Settings

+

Configure how long tokens remain valid. Shorter times are more secure but require more frequent refreshes.

+ +
+
+ <%= form.label :access_token_ttl, "Access Token TTL (seconds)", class: "block text-sm font-medium text-gray-700" %> + <%= form.number_field :access_token_ttl, value: application.access_token_ttl || 3600, min: 300, max: 86400, step: 60, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %> +

+ Range: 5 min - 24 hours +
Default: 1 hour (3600s) +
Current: <%= application.access_token_ttl_human || "1 hour" %> +

+
+ +
+ <%= form.label :refresh_token_ttl, "Refresh Token TTL (seconds)", class: "block text-sm font-medium text-gray-700" %> + <%= form.number_field :refresh_token_ttl, value: application.refresh_token_ttl || 2592000, min: 86400, max: 7776000, step: 86400, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %> +

+ Range: 1 day - 90 days +
Default: 30 days (2592000s) +
Current: <%= application.refresh_token_ttl_human || "30 days" %> +

+
+ +
+ <%= form.label :id_token_ttl, "ID Token TTL (seconds)", class: "block text-sm font-medium text-gray-700" %> + <%= form.number_field :id_token_ttl, value: application.id_token_ttl || 3600, min: 300, max: 86400, step: 60, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %> +

+ Range: 5 min - 24 hours +
Default: 1 hour (3600s) +
Current: <%= application.id_token_ttl_human || "1 hour" %> +

+
+
+ +
+ Understanding Token Types +
+

Access Token: Used to access protected resources (APIs). Shorter lifetime = more secure. Users won't notice automatic refreshes.

+

Refresh Token: Used to get new access tokens without re-authentication. Longer lifetime = better UX (less re-logins).

+

ID Token: Contains user identity information (JWT). Should match access token lifetime in most cases.

+

💡 Tip: Banking apps use 5-15 min access tokens. Internal tools use 1-4 hours.

+
+
+
diff --git a/config/routes.rb b/config/routes.rb index 6cbb3ae..152f801 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -29,6 +29,7 @@ Rails.application.routes.draw do get "/oauth/authorize", to: "oidc#authorize" post "/oauth/authorize/consent", to: "oidc#consent", as: :oauth_consent post "/oauth/token", to: "oidc#token" + post "/oauth/revoke", to: "oidc#revoke" get "/oauth/userinfo", to: "oidc#userinfo" get "/logout", to: "oidc#logout" diff --git a/db/schema.rb b/db/schema.rb index 4b67cd6..a4378c1 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2025_11_09_011443) do +ActiveRecord::Schema[8.1].define(version: 2025_11_12_120314) do create_table "application_groups", force: :cascade do |t| t.integer "application_id", null: false t.datetime "created_at", null: false @@ -22,6 +22,7 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_09_011443) do end create_table "applications", force: :cascade do |t| + t.integer "access_token_ttl", default: 3600 t.boolean "active", default: true, null: false t.string "app_type", null: false t.string "client_id" @@ -30,10 +31,12 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_09_011443) do t.text "description" t.string "domain_pattern" t.json "headers_config", default: {}, null: false + t.integer "id_token_ttl", default: 3600 t.string "landing_url" t.text "metadata" t.string "name", null: false t.text "redirect_uris" + t.integer "refresh_token_ttl", default: 2592000 t.string "slug", null: false t.datetime "updated_at", null: false t.index ["active"], name: "index_applications_on_active" @@ -55,14 +58,18 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_09_011443) do t.integer "application_id", null: false t.datetime "created_at", null: false t.datetime "expires_at", null: false + t.datetime "revoked_at" t.string "scope" - t.string "token", null: false + t.string "token" + t.string "token_digest" t.datetime "updated_at", null: false t.integer "user_id", null: false t.index ["application_id", "user_id"], name: "index_oidc_access_tokens_on_application_id_and_user_id" t.index ["application_id"], name: "index_oidc_access_tokens_on_application_id" t.index ["expires_at"], name: "index_oidc_access_tokens_on_expires_at" + t.index ["revoked_at"], name: "index_oidc_access_tokens_on_revoked_at" t.index ["token"], name: "index_oidc_access_tokens_on_token", unique: true + t.index ["token_digest"], name: "index_oidc_access_tokens_on_token_digest", unique: true t.index ["user_id"], name: "index_oidc_access_tokens_on_user_id" end @@ -87,6 +94,27 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_09_011443) do t.index ["user_id"], name: "index_oidc_authorization_codes_on_user_id" end + create_table "oidc_refresh_tokens", force: :cascade do |t| + t.integer "application_id", null: false + t.datetime "created_at", null: false + t.datetime "expires_at", null: false + t.integer "oidc_access_token_id", null: false + t.datetime "revoked_at" + t.string "scope" + t.string "token_digest", null: false + t.integer "token_family_id" + t.datetime "updated_at", null: false + t.integer "user_id", null: false + t.index ["application_id", "user_id"], name: "index_oidc_refresh_tokens_on_application_id_and_user_id" + t.index ["application_id"], name: "index_oidc_refresh_tokens_on_application_id" + t.index ["expires_at"], name: "index_oidc_refresh_tokens_on_expires_at" + t.index ["oidc_access_token_id"], name: "index_oidc_refresh_tokens_on_oidc_access_token_id" + t.index ["revoked_at"], name: "index_oidc_refresh_tokens_on_revoked_at" + t.index ["token_digest"], name: "index_oidc_refresh_tokens_on_token_digest", unique: true + t.index ["token_family_id"], name: "index_oidc_refresh_tokens_on_token_family_id" + t.index ["user_id"], name: "index_oidc_refresh_tokens_on_user_id" + end + create_table "oidc_user_consents", force: :cascade do |t| t.integer "application_id", null: false t.datetime "created_at", null: false @@ -174,6 +202,9 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_09_011443) do add_foreign_key "oidc_access_tokens", "users" add_foreign_key "oidc_authorization_codes", "applications" add_foreign_key "oidc_authorization_codes", "users" + add_foreign_key "oidc_refresh_tokens", "applications" + add_foreign_key "oidc_refresh_tokens", "oidc_access_tokens" + add_foreign_key "oidc_refresh_tokens", "users" add_foreign_key "oidc_user_consents", "applications" add_foreign_key "oidc_user_consents", "users" add_foreign_key "sessions", "users"