Hello :wave: I've been researching how OAuth2 wor...
# _newcomer
q
Hello ๐Ÿ‘‹ I've been researching how OAuth2 works as I'm kind of new to it and I'm testing it locally. I'm currently having some problems with logging in regarding the csrf cookies. I'm looking for someone that could guide me into the right direction. ๐Ÿงต I'll provide some extra information in the thread below.
I have set up a
docker-compose
file that would spin up the services as suggested in the docs:
Copy code
version: "3.8"

services:
  api:
    build:
      context: .
      dockerfile: cmd/api/Dockerfile
    ports:
      - "3000:3000"
    depends_on:
      - db
    environment:
      - DATABASE_URL=<postgres://admin:admin@db:5432/api_db?sslmode=disable>

  login:
    build:
      context: .
      dockerfile: cmd/login/Dockerfile
    ports:
      - "3001:3000"
    environment:
      - APP_NAME=login-handler
      - HYDRA_ADMIN_URL=<http://hydra:4445>
      - KRATOS_URL=<http://127.0.0.1:4433>
      - KRATOS_INTERNAL_URL=<http://kratos:4433>
    depends_on:
      - kratos
      - hydra

  db:
    image: postgres:16.4-alpine
    environment:
      POSTGRES_USER: admin
      POSTGRES_PASSWORD: admin
      POSTGRES_DB: api_db
    volumes:
      - postgres_data:/var/lib/postgresql/data

  kratos-db:
    image: postgres:16.4-alpine
    environment:
      POSTGRES_USER: kratos
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: kratos
    volumes:
      - kratos_postgres_data:/var/lib/postgresql/data

  kratos-migrate:
    image: oryd/kratos:v1.2.0
    depends_on:
      - kratos-db
    environment:
      - DSN=<postgres://kratos:secret@kratos-db:5432/kratos?sslmode=disable>
    volumes:
      - type: bind
        source: ./ory
        target: /etc/config/kratos
    command: >
      migrate sql -e --yes -c /etc/config/kratos/kratos.yml
    restart: on-failure

  kratos:
    depends_on:
      - kratos-migrate
    image: oryd/kratos:v1.2.0
    ports:
      - "4433:4433" # public
      - "4434:4434" # admin
    restart: unless-stopped
    environment:
      - DSN=<postgres://kratos:secret@kratos-db:5432/kratos?sslmode=disable>
    command: >
      serve --dev --watch-courier -c /etc/config/kratos/kratos.yml
    volumes:
      - type: bind
        source: ./ory
        target: /etc/config/kratos

  hydra-db:
    image: postgres:16.4-alpine
    environment:
      POSTGRES_USER: hydra
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: hydra
    volumes:
      - hydra_postgres_data:/var/lib/postgresql/data

  hydra-migrate:
    image: oryd/hydra:v2.2.0
    environment:
      - DSN=<postgres://hydra:secret@hydra-db:5432/hydra?sslmode=disable>
    command: migrate -c /etc/config/hydra/hydra.yml sql -e --yes
    volumes:
      - type: bind
        source: ./ory
        target: /etc/config/hydra
    restart: on-failure
    depends_on:
      - hydra-db

  hydra:
    image: oryd/hydra:v2.2.0
    ports:
      - "4444:4444" # Public port
      - "4445:4445" # Admin port
      - "5555:5555" # Port for hydra token user
    volumes:
      - type: bind
        source: ./ory
        target: /etc/config/hydra
    environment:
      - DSN=<postgres://hydra:secret@hydra-db:5432/hydra?sslmode=disable>
      - LOG_LEAK_SENSITIVE_VALUES=true
    command: serve -c /etc/config/hydra/hydra.yml all --dev
    restart: unless-stopped
    depends_on:
      - hydra-migrate

volumes:
  postgres_data:
  kratos_postgres_data:
  hydra_postgres_data:
This spins up the Kratos and Ory services along with my custom login handler which would provide the users with the custom UI to login. I would later on use it as well for the consent page. (depending on which client initiates the login, first party or third party) The api container would be my api which would need an access token provided by Hydra.
This is my Hydra config file:
Copy code
urls:
  self:
    issuer: <http://127.0.0.1:4444>
    public: <http://127.0.0.1:4444>
  consent: <http://127.0.0.1:3001/consent>
  login: <http://127.0.0.1:3001/login>
  logout: <http://127.0.0.1:3001/logout>

secrets:
  system:
    - youReallyNeedToChangeThis

oidc:
  subject_identifiers:
    supported_types:
      - pairwise
      - public
    pairwise:
      salt: youReallyNeedToChangeThis
Along with my Kratos config file:
Copy code
serve:
  public:
    base_url: <http://127.0.0.1:4433/>
  admin:
    base_url: <http://kratos:4434/>

selfservice:
  default_browser_return_url: <http://127.0.0.1:3001/>
  allowed_return_urls:
    - <http://127.0.0.1:3001/>

  methods:
    password:
      enabled: true

  flows:
    settings:
      ui_url: <http://127.0.0.1:3001/settings>

    verification:
      ui_url: <http://127.0.0.1:3001/verification>
      enabled: false

    recovery:
      ui_url: <http://127.0.0.1:3001/recovery>
      enabled: false

    logout:
      after:
        default_browser_return_url: <http://127.0.0.1:3001/login>

    login:
      ui_url: <http://127.0.0.1:3001/login>

    registration:
      ui_url: <http://127.0.0.1:3001/registration>
      after:
        password:
          hooks:
            - hook: session
    error:
      ui_url: <http://127.0.0.1:3001/error>

log:
  level: debug

hashers:
  argon2:
    parallelism: 1
    memory: 128KB
    iterations: 2
    salt_length: 16
    key_length: 16

identity:
  schemas:
    - id: default
      url: file:///etc/config/kratos/identity.schema.json

oauth2_provider:
  url: <http://hydra:4445>
In my
login-service
I have the following route set up to handle the login functionality:
Copy code
package api

import (
	"encoding/json"
	"fmt"
	"log/slog"
	"net/http"
	"os"
)

var (
	hydraAdminUrl     = os.Getenv("HYDRA_ADMIN_URL")
	kratosUrl         = os.Getenv("KRATOS_URL")
	kratosInternalUrl = os.Getenv("KRATOS_INTERNAL_URL")
)

type Api struct {
	router *http.ServeMux
}

func (api *Api) Router() http.Handler {
	return applyMiddleware(api.router, requestLogger, applicationJson)
}

func New() *Api {
	api := new(Api)

	api.router = http.NewServeMux()
	api.router.HandleFunc("/login", api.handleLogin)

	return api
}

func (api *Api) handleLogin(w http.ResponseWriter, r *http.Request) {
	queryParams := r.URL.Query()
	logAttrs := []slog.Attr{}

	for param, values := range queryParams {
		for _, value := range values {
			logAttrs = append(logAttrs, slog.String(param, value))
		}
	}

	logAttrsAny := make([]any, len(logAttrs))
	for i, attr := range logAttrs {
		logAttrsAny[i] = attr
	}

	slog.Debug("Query parameters", logAttrsAny...)

	flowId := queryParams.Get("flow")
	loginChallenge := queryParams.Get("login_challenge")
	if flowId == "" && loginChallenge == "" {
		respondWithError(w, http.StatusBadRequest, "The request was malformed, the query parameter should either contain a flow id or a login challenge.")
		return
	}

	if flowId != "" && loginChallenge != "" {
		respondWithError(w, http.StatusBadRequest, "The request was malformed, both flow and login_challenge cannot be present simultaneously.")
		return
	}

	if loginChallenge != "" {
		kratosBrowserLoginUrl := fmt.Sprintf("%s/self-service/login/browser?login_challenge=%s", kratosUrl, loginChallenge)
		http.Redirect(w, r, kratosBrowserLoginUrl, http.StatusFound)
		return
	}

	if flowId != "" {
		client := &http.Client{}

		kratosLoginUrl := fmt.Sprintf("%s/self-service/login/flows?id=%s", kratosInternalUrl, flowId)
		req, err := http.NewRequest("GET", kratosLoginUrl, nil)
		if err != nil {
			slog.Error("Could not create get request", slog.String("error", err.Error()))
			respondWithError(w, http.StatusInternalServerError, "Could not create request to the identity server.")
			return
		}
		resp, err := <http://client.Do|client.Do>(req)
		if err != nil {
			slog.Error("Failed to connect to identity server", slog.String("error", err.Error()))
			respondWithError(w, http.StatusInternalServerError, "Failed to connect to the identity server.")
			return
		}
		defer resp.Body.Close()

		if resp.StatusCode != http.StatusOK {
			respondWithError(w, http.StatusInternalServerError, "Identity service returned an error.")
			return
		}

		// Parse response from Kratos
		var kratosResponse map[string]any
		if err := json.NewDecoder(resp.Body).Decode(&kratosResponse); err != nil {
			slog.Error("Error decoding response from Kratos", slog.String("error", err.Error()))
			respondWithError(w, http.StatusInternalServerError, "Failed to parse identity service response.")
			return
		}

		ui, ok := kratosResponse["ui"]
		if !ok {
			respondWithError(w, http.StatusInternalServerError, "Identity service response does not contain expected UI object.")
			return
		}

		slog.Debug("Kratos UI object", slog.Any("ui", ui))

		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(http.StatusOK)
		json.NewEncoder(w).Encode(ui)
		return
	}
}
This is the part where I got stuck, as I got an error on retrieving the login flow:
Copy code
login-1           | {"time":"2024-09-27T08:05:23.847220543Z","level":"DEBUG","msg":"Incoming request","method":"GET","path":"/login"}
login-1           | {"time":"2024-09-27T08:05:23.847303668Z","level":"DEBUG","msg":"Query parameters","flow":"e4f66ba2-d812-40b9-8f1f-7a2485d3f9c1"}
kratos-1          | time=2024-09-27T08:05:23Z level=info msg=started handling request http_request=map[headers:map[accept-encoding:gzip user-agent:Go-http-client/1.1] host:kratos:4433 method:GET path:/self-service/login/flows query:Value is sensitive and has been redacted. To see the value set config key "log.leak_sensitive_values = true" or environment variable "LOG_LEAK_SENSITIVE_VALUES=true". remote:172.18.0.10:46510 scheme:http]
kratos-1          | time=2024-09-27T08:05:23Z level=info msg=An error occurred while handling a request audience=application error=map[debug: details:map[docs:<https://www.ory.sh/kratos/docs/debug/csrf> hint:The anti-CSRF cookie was found but the CSRF token was not included in the HTTP request body (csrf_token) nor in the HTTP Header (X-CSRF-Token). reject_reason:The HTTP Cookie Header was set and a CSRF token was sent but they do not match. We recommend deleting all cookies for this domain and retrying the flow.] message:the request was rejected to protect you from Cross-Site-Request-Forgery reason:Please retry the flow and optionally clear your cookies. The request was rejected to protect you from Cross-Site-Request-Forgery (CSRF) which could cause account takeover, leaking personal information, and other serious security issues.
I initiated the login request via a React SPA run locally on port
5173
and it looks like the csrf cookie was stored to this domain/port instead of my local login api handler. Which I assume is why I'm getting this CSRF error. I'm using the oauth2 client that was suggested in the docs: https://github.com/badgateway/oauth2-client And this is the code that I have in `oauth2.ts`:
Copy code
import { OAuth2Client } from "@badgateway/oauth2-client";

// TODO: Set these values via variables for environment specific builds
export const client = new OAuth2Client({
  server: "<http://localhost:4444>",
  clientId: "fcc449ab-d933-4557-a097-8a6e5ff5b865",
  tokenEndpoint: "/oauth2/token",
  authorizationEndpoint: "/oauth2/auth",
  discoveryEndpoint: "/.well-known/oauth2-authorization-server",
});

export const generateRandomState = (): string => {
  return (
    Math.random().toString(36).substring(2, 15) +
    Math.random().toString(36).substring(2, 15)
  );
};
and in my
App.tsx
:
Copy code
const [pkceCodeVerifier, setPkceCodeVerifier] = useState("");

  useEffect(() => {
    async function initiateAuthFlow() {
      const verifier = await generateCodeVerifier();
      setPkceCodeVerifier(verifier);

      // TODO: Set these values via variables for environment specific builds
      const authUri = await client.authorizationCode.getAuthorizeUri({
        redirectUri: "<http://localhost:5173>",
        state: generateRandomState(),
        codeVerifier: verifier,
        scope: ["openid", "offline"],
      });

      window.location.href = authUri;
    }

    if (!pkceCodeVerifier) {
      initiateAuthFlow();
    }
  }, [pkceCodeVerifier]);
Which would initiate a login flow when you enter the page.
If any information is missing, I'd be happy to provide more. Thank you for taking the time looking into this!
UPDATE I found the issue ๐Ÿคฆโ€โ™‚๏ธ I forgot to add the Cookie header. Thanks to somebody if he was already looking into this