# frozen_string_literal: true

# OpenID Connect Access Token Request (RFC 6749 section 4.1.3.)

module PuavoRest
module OAuth2
module AccessTokenRequest

# Generate ID and access tokens for the client
def oidc_access_token_request(temp_request_id)
  # ----------------------------------------------------------------------------------------------
  # Retrive the code and the current state

  code = params.fetch('code', nil)

  if code.nil?
    # Tested
    rlog.error("[#{temp_request_id}] No \"code\" parameter in the request")
    json_error('invalid_request', request_id: temp_request_id)
  end

  # The code must be from our system, verbatim. (Strictly speaking, this check is not needed. If the
  # code isn't valid, the Redis query won't return anything. But I hate to pass raw user inputs to
  # anything database-like. This check ensures the string shouldn't contain anything unruly.)
  unless /\A[a-fA-F0-9]{128}\Z/.match(code)
    rlog.error("[#{temp_request_id}] The specified code (#{code.inspect}) was not generated by us")
    json_error('invalid_request', request_id: temp_request_id)
  end

  begin
    oidc_state = oidc_redis.get(code)
  rescue StandardError => e
    # Don't log the secret (we know it already, but don't log it)
    params['client_secret'] = '[REDACTED]' if params.include?('client_secret')

    rlog.error("[#{temp_request_id}] An attempt to get OIDC state from Redis raised an exception: #{e}")
    rlog.error("[#{temp_request_id}] Request parameters: #{params.inspect}")
    json_error('server_error', request_id: temp_request_id)
  end

  if oidc_state.nil?
    # Tested
    rlog.error("[#{temp_request_id}] No OpenID Connect state found by code \"#{code}\"")
    json_error('invalid_request', request_id: temp_request_id)
  end

  # Prevent code reuse, even if we cannot parse the state or an error occurrs
  oidc_redis.del(code)

  begin
    oidc_state = JSON.parse(oidc_state)
  rescue StandardError => e
    # Tested (manually)
    rlog.error("[#{temp_request_id}] Unable to parse the JSON in OIDC state \"#{code}\": #{e}")
    json_error('server_error', request_id: temp_request_id)
  end

  state = oidc_state['state'].freeze

  request_id = oidc_state['request_id']
  rlog.info("[#{temp_request_id}] Resuming OIDC flow for request ID \"#{request_id}\"")
  rlog.info("[#{request_id}] OIDC stage 3 token generation for state \"#{code}\"")

  # ----------------------------------------------------------------------------------------------
  # Verify the redirect URI

  # This has to be the same address where the response was sent at the end of stage 2.
  # The RFC says this is OPTIONAL, but just like in stage 1, we require it. We can't
  # even get past stage 1 if the URL is not allowed.
  redirect_uri = params.fetch('redirect_uri', nil)

  unless redirect_uri == oidc_state['redirect_uri']
    # Tested
    rlog.error("[#{request_id}] Mismatching redirect URIs: got \"#{redirect_uri}\", "  \
               "expected \"#{oidc_state['redirect_uri']}\"")
    json_error('invalid_request', request_id: temp_request_id)
  end

  # From here on, the redirect URI is usable, if needed

  # ----------------------------------------------------------------------------------------------
  # Verify the client ID and secret

  client_id = params.fetch('client_id', nil)

  rlog.info("[#{request_id}] Client ID: #{client_id.inspect}")

  if client_id.nil?
    # Tested
    rlog.error("[#{request_id}] No client_id in the request")
    json_error('unauthorized_client', state: state, request_id: request_id)
  end

  unless OAuth2.valid_client_id?(client_id)
    # Tested
    rlog.error("[#{request_id}] Malformed client ID")
    json_error('unauthorized_client', state: state, request_id: request_id)
  end

  unless client_id == oidc_state['client_id']
    # Tested
    rlog.error("[#{request_id}] The client ID has changed: got \"#{client_id}\", " \
               "expected \"#{oidc_state['client_id']}\"")
    json_error('unauthorized_client', state: state, request_id: request_id)
  end

  begin
    external_service = get_external_service(oidc_state['service']['dn'])
  rescue StandardError => e
    # Tested (manually; I think this can fail only if the service is edited and the DN changes
    # half-way through someone's login process, not very common)
    rlog.error("[#{request_id}] Could not get the external service: #{e}")
    json_error('unauthorized_client', state: state, request_id: request_id)
  end

  client_secret = params.fetch('client_secret', nil)

  if external_service.secret.start_with?('$argon2')
    # Secure Argon2 hash comparison
    match = Argon2::Password.verify_password(client_secret, external_service.secret)
    was_hashed = true
  else
    # Insecure plaintext comparison
    match = client_secret == external_service.secret
    was_hashed = false
  end

  unless match
    # Tested
    rlog.error("[#{request_id}] Invalid client secret in the request (using hashed secret: #{was_hashed})")
    json_error('unauthorized_client', state: state, request_id: request_id)
  end

  rlog.info("[#{request_id}] Client authenticated (using hashed secret: #{was_hashed})")

  # ----------------------------------------------------------------------------------------------
  # Re-verify the client configuration

  clients = ClientDatabase.new
  client_config = clients.get_client_by_id(request_id, client_id, :login)
  clients.close

  if client_config.nil?
    # Tested (manually, there are multiple checks for this and they all have to fail)
    rlog.error("[#{request_id}] Unknown/invalid client (it existed in stage 1)")
    json_error('unauthorized_client', state: state, request_id: request_id)
  end

  unless client_config['enabled'] == 't'
    # Tested
    rlog.error("[#{request_id}] This client exists but it has been disabled (it was enabled in stage 1)")
    json_error('unauthorized_client', state: state, request_id: request_id)
  end

  # ----------------------------------------------------------------------------------------------
  # Verify the scopes

  # TODO: If scopes are specified, they must be compared against the scopes that were
  # specified in the original authorization request. The scopes must be identical or
  # a subset. If they include new scopes, the request must be rejected.

  # TODO: I don't know what to do with the new scopes. Do we use them below, or do we
  # use the original scopes? I don't know. I can't find any specifications for this,
  # nor any examples. RFC 6749 simply mentions it's possible to specify the scopes
  # again this call but fails to elaborate further.

  # For now, we always proceed with the original scopes and ignore the new scopes.

  # ----------------------------------------------------------------------------------------------
  # All good. Build the access and ID tokens.

  # TODO: Should this be client-configurable?
  expires_in = 3600
  now = Time.now.utc.to_i

  # Build the access token. Currently it's only usable with the userinfo endpoint,
  # as the scope names are different.
  token = build_access_token(
    request_id,
    subject: oidc_state['user']['uuid'],
    audience: 'puavo-rest-userinfo',      # this token is only usable in the userinfo endpoint
    scopes: oidc_state['scopes'],
    expires_in: expires_in,
    ldap_user_dn: CONFIG['oauth2']['userinfo_dn'],

    # These are hard to determine afterwards, so stash them in the token
    # (These are for the userinfo endpoint; it works because auth() stores the full
    # token in the credentials data and we can dig these up from it.)
    custom_claims: {
      'allowed_endpoints' => ['/oidc/userinfo'],
      'organisation_domain' => oidc_state['organisation']['domain'],
      'user_dn' => oidc_state['user']['dn']
    }
  )

  unless token[:success]
    # TODO: Need to test this
    json_error('invalid_request', request_id: request_id)
  end

  id_token = {
    'iss' => ISSUER,
    'jti' => SecureRandom.uuid,
    'sub' => oidc_state['user']['uuid'],
    'aud' => oidc_state['client_id'],
    'iat' => now,
    'nbf' => now,
    'exp' => now + expires_in,
    'azp' => client_id,         # not sure if we need this
    'auth_time' => oidc_state['auth_time']
  }

  id_token['amr'] = oidc_state['user']['amr'] unless oidc_state['user']['amr'].empty?

  if oidc_state.include?('nonce')
    # If the nonce was present in the original request, send it back so that the client can verify it
    id_token['nonce'] = oidc_state['nonce']
  end

  # Calculate the access token hash
  id_token['at_hash'] = base64_half_hash(token[:access_token])

  # Calculate the authorization code hash
  id_token['c_hash'] = base64_half_hash(code)

  # Collect the user data and append it to the ID token
  begin
    user_data = IDTokenDataGenerator.new(request_id).generate(
      ldap_credentials: {
        dn: CONFIG['oauth2']['userinfo_dn'],
        password: CONFIG['oauth2']['ldap_accounts'][CONFIG['oauth2']['userinfo_dn']]
      },
      domain: oidc_state['organisation']['domain'],
      user_dn: oidc_state['user']['dn'],
      scopes: oidc_state['scopes'],
    )

    json_error(user_data, request_id: request_id) if user_data.instance_of?(String)
  rescue StandardError => e
    # Tested (manually)
    rlog.error("[#{request_id}] Could not gather the user data: #{e}")
    json_error('server_error', state: state, request_id: request_id)
  end

  id_token.merge!(user_data)

  # Load the signing private key. Unlike the public key, this is not kept in memory.
  begin
    private_key = OpenSSL::PKey.read(File.open(CONFIG['oauth2']['token_key']['private_file']))
  rescue StandardError => e
    # Tested (manually)
    rlog.error("[#{request_id}] Cannot load the access token signing private key file: #{e}")
    json_error('server_error', state: state, request_id: request_id)
  end

  rlog.info("[#{request_id}] Issued access token #{token[:raw_token]['jti'].inspect} " \
            "for the user, expires at #{Time.at(token[:expires_at])}")

  audit_issued_id_token(request_id,
                        client_id: client_id,
                        ldap_user_dn: CONFIG['server'][:dn],
                        raw_requested_scopes: oidc_state['original_scopes'],
                        issued_scopes: oidc_state['scopes'],
                        redirect_uri: oidc_state['redirect_uri'],
                        raw_token: id_token,
                        request: request)

  audit_issued_access_token(request_id,
                            client_id: client_id,
                            ldap_user_dn: CONFIG['server'][:dn],
                            raw_requested_scopes: oidc_state['original_scopes'],
                            raw_token: token[:raw_token],
                            request: request)

  # ----------------------------------------------------------------------------------------------
  # Assemble the final bearer token and return it

  bearer_token = {
    'access_token' => token[:access_token],
    'token_type' => 'Bearer',
    'expires_in' => expires_in,
    'id_token' => JWT.encode(id_token, private_key, 'ES256', { typ: 'at+jwt' }),
    'puavo_request_id' => request_id
  }

  if oidc_state['scopes_changed']
    # See the stage 2 handler for explanation
    # Tested
    bearer_token['scopes'] = oidc_state['scopes'].join(' ')
  end

  headers['Cache-Control'] = 'no-store'
  headers['Pragma'] = 'no-cache'

  json(bearer_token)
rescue StandardError => e
  # Tested (manually)
  rlog.info("[#{request_id}] Unhandled exception: #{e}")
  json_error('server_error', state: state, request_id: request_id)
end

def build_access_token(request_id,
                       scopes: [],
                       client_id: nil,
                       subject: nil,
                       audience: 'puavo-rest-v4',
                       expires_in: 3600,
                       ldap_user_dn: nil,
                       custom_claims: nil)

now = Time.now.utc.to_i

token_claims = {
  'jti' => SecureRandom.uuid,
  'iat' => now,
  'nbf' => now,
  'exp' => now + expires_in,
  'iss' => ISSUER,
  'sub' => subject,
  'aud' => audience,
  'scopes' => scopes.join(' ')
}

token_claims['client_id'] = client_id if client_id

token_claims['ldap_user_dn'] = ldap_user_dn if ldap_user_dn

token_claims.merge!(custom_claims) if custom_claims.is_a?(Hash)

# Load the signing private key. Unlike the public key, this is not kept in memory.
begin
  private_key = OpenSSL::PKey.read(File.open(CONFIG['oauth2']['token_key']['private_file']))
rescue StandardError => e
  # Tested (manually)
  rlog.error("[#{request_id}] Cannot load the access token signing private key file: #{e}")
  return { success: false }
end

# TODO: Support encrypted tokens with JWE? The "jwe" gem is already installed.
# TODO: Install the jwt-eddsa gem and use EdDSA signing? Is it compatible with JWT?

# Sign the token data using the private key. RFC 9068 section 2.1. says the "typ" value
# SHOULD be "at+jwt", but the JWT gem does not set it, so let's set it manually.
# (I have no idea what I'm doing.)
access_token = JWT.encode(token_claims, private_key, 'ES256', { typ: 'at+jwt' })

{
  success: true,
  access_token: access_token,
  raw_token: token_claims,        # some places, like auditing, needs to see the raw data
  expires_at: now + expires_in
}
end

# Returns a base64-encoded "half hash", ie. the first half (16 bytes) of a SHA256 hash of the input data.
# This hash format is used in some ID token claims. The client can use them to verify the data has been
# tampered with. I'm not sure why they only use half of a hash, but I'm speculating it is done to prevent
# length extension attacks (which SHA256 is susceptible to).
def base64_half_hash(data)
  Base64.strict_encode64(Digest::SHA256.digest(data)[0..16])
end

end   # module AccessTokenRequest
end   # module OAuth2
end   # module PuavoRest
