# Go (https://docs.idra.zone/docs/quickstarts/go)

Add IdRa sign-in to a Go net/http server with go-oidc and x/oauth2.

A `net/http` server using the authorization code flow with PKCE, [`go-oidc`](https://github.com/coreos/go-oidc) and `golang.org/x/oauth2`. Go 1.26 or later; tested with Go 1.27.

Sample repository (being opened soon): [IdRaHQ/idra-sample-go](https://github.com/IdRaHQ/idra-sample-go). It has this quickstart's code plus a protected page.

## Register the app

Use one app for local development and production. Open your organization's admin console at `https://<your-org>.idra.team/admin` (or `/admin` on your own IdRa). Go to **Applications → Create App Integration**, choose **OIDC - OpenID Connect**, and set:

- **Application type:** **Web Application**
- **App integration name:** any name
- **Sign-in redirect URIs:** `http://localhost:8080/callback`

Leave **Assign this app to me so I can test it** on, then select **Create**. Copy the client secret, shown **once**, and select **I've saved it**.

The app's **Sign On** tab opens:

1. Under **Connect your app**, copy the **Issuer** and **Client ID**. The `.env` snippet there has the same values, named `OIDC_*`; this quickstart names them `IDRA_*`.
2. Add `http://localhost:8080/` to **Sign-out redirect URIs** and select **Save**.

To let other people sign in, assign them or their groups on **Assignments**.

**Local redirects and production**

  `http` redirects work on `localhost`, `127.0.0.1` or `[::1]`, with a port, and match exactly. PKCE stays required while the app has one. For production, add your `https` URIs (such as `https://app.example.com/callback` and `https://app.example.com/`) to the same app, then remove the local ones.

## Configure

```sh title=".env"
IDRA_ISSUER=https://<your-org>.idra.team/api/auth
IDRA_CLIENT_ID=<Client ID>
IDRA_CLIENT_SECRET=<client secret>
APP_URL=http://localhost:8080
```

## Install

```sh
mkdir idra-go && cd idra-go
go mod init idra-go-quickstart
go get github.com/coreos/go-oidc/v3@v3.21.0 golang.org/x/oauth2@v0.37.0
```

## Code

```go title="main.go"
package main

import (
	"context"
	"crypto/rand"
	"encoding/json"
	"fmt"
	"html"
	"log"
	"net/http"
	"net/url"
	"os"
	"sync"

	"github.com/coreos/go-oidc/v3/oidc"
	"golang.org/x/oauth2"
)

type session struct {
	Verifier, State, Nonce string
	User                   map[string]any
	IDToken                string
}

// ponytail: sessions live in memory, so they end on restart and don't span replicas. Use a shared store in production.
var (
	sessions = map[string]*session{}
	mu       sync.Mutex
)

// sessionFor returns the browser's session, creating it (and its cookie) if needed.
func sessionFor(w http.ResponseWriter, r *http.Request, secure bool) *session {
	mu.Lock()
	defer mu.Unlock()
	if c, err := r.Cookie("app_session"); err == nil && sessions[c.Value] != nil {
		return sessions[c.Value]
	}
	id := rand.Text()
	sessions[id] = &session{}
	http.SetCookie(w, &http.Cookie{
		Name: "app_session", Value: id, Path: "/", HttpOnly: true, Secure: secure, SameSite: http.SameSiteLaxMode,
	})
	return sessions[id]
}

func main() {
	ctx := context.Background()
	appURL := os.Getenv("APP_URL")
	clientID := os.Getenv("IDRA_CLIENT_ID")
	secret := os.Getenv("IDRA_CLIENT_SECRET")
	secure := len(appURL) > 5 && appURL[:5] == "https"

	provider, err := oidc.NewProvider(ctx, os.Getenv("IDRA_ISSUER"))
	if err != nil {
		log.Fatal(err)
	}
	var meta struct {
		EndSession string `json:"end_session_endpoint"`
	}
	if err := provider.Claims(&meta); err != nil {
		log.Fatal(err)
	}
	endpoint := provider.Endpoint()
	// With a secret: client_secret_basic. Without one: a public client.
	endpoint.AuthStyle = oauth2.AuthStyleInParams
	if secret != "" {
		endpoint.AuthStyle = oauth2.AuthStyleInHeader
	}
	conf := oauth2.Config{
		ClientID:     clientID,
		ClientSecret: secret,
		Endpoint:     endpoint,
		RedirectURL:  appURL + "/callback",
		Scopes:       []string{oidc.ScopeOpenID, "profile", "email", "groups"},
	}
	verifier := provider.Verifier(&oidc.Config{ClientID: clientID})

	http.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
		s := sessionFor(w, r, secure)
		w.Header().Set("Content-Type", "text/html")
		if s.User == nil {
			fmt.Fprint(w, `<h1>Signed out</h1><a href="/login">Sign in with IdRa</a>`)
			return
		}
		body, _ := json.MarshalIndent(s.User, "", "  ")
		fmt.Fprintf(w, `<h1>Signed in</h1><pre>%s</pre><a href="/logout">Sign out</a>`, html.EscapeString(string(body)))
	})

	http.HandleFunc("GET /login", func(w http.ResponseWriter, r *http.Request) {
		s := sessionFor(w, r, secure)
		mu.Lock()
		s.Verifier, s.State, s.Nonce = oauth2.GenerateVerifier(), rand.Text(), rand.Text()
		url := conf.AuthCodeURL(s.State, oauth2.S256ChallengeOption(s.Verifier), oidc.Nonce(s.Nonce))
		mu.Unlock()
		http.Redirect(w, r, url, http.StatusFound)
	})

	http.HandleFunc("GET /callback", func(w http.ResponseWriter, r *http.Request) {
		s := sessionFor(w, r, secure)
		mu.Lock()
		verifierValue, state, nonce := s.Verifier, s.State, s.Nonce
		mu.Unlock()
		user, idToken, err := func() (map[string]any, string, error) {
			if q := r.URL.Query(); q.Get("state") != state || state == "" {
				return nil, "", fmt.Errorf("state mismatch")
			} else if e := q.Get("error"); e != "" {
				return nil, "", fmt.Errorf("%s: %s", e, q.Get("error_description"))
			}
			token, err := conf.Exchange(r.Context(), r.URL.Query().Get("code"), oauth2.VerifierOption(verifierValue))
			if err != nil {
				return nil, "", err
			}
			raw, _ := token.Extra("id_token").(string)
			idt, err := verifier.Verify(r.Context(), raw)
			if err != nil {
				return nil, "", err
			}
			if idt.Nonce != nonce {
				return nil, "", fmt.Errorf("nonce mismatch")
			}
			// The ID token has no email or name: they come from UserInfo.
			info, err := provider.UserInfo(r.Context(), oauth2.StaticTokenSource(token))
			if err != nil {
				return nil, "", err
			}
			var claims map[string]any
			if err := info.Claims(&claims); err != nil {
				return nil, "", err
			}
			return claims, raw, nil
		}()
		if err != nil {
			log.Print(err)
			http.Error(w, "Sign-in failed: "+err.Error(), http.StatusBadRequest)
			return
		}
		mu.Lock()
		s.User, s.IDToken = user, idToken
		mu.Unlock()
		http.Redirect(w, r, "/", http.StatusFound)
	})

	http.HandleFunc("GET /logout", func(w http.ResponseWriter, r *http.Request) {
		s := sessionFor(w, r, secure)
		mu.Lock()
		idToken := s.IDToken
		s.User, s.IDToken = nil, ""
		mu.Unlock()
		if idToken == "" {
			http.Redirect(w, r, "/", http.StatusFound)
			return
		}
		q := url.Values{"id_token_hint": {idToken}, "post_logout_redirect_uri": {appURL + "/"}}
		http.Redirect(w, r, meta.EndSession+"?"+q.Encode(), http.StatusFound)
	})

	port := os.Getenv("PORT")
	if port == "" {
		port = "8080"
	}
	log.Printf("Listening on %s", appURL)
	log.Fatal(http.ListenAndServe(":"+port, nil))
}
```

## Run

```sh
set -a; source .env; set +a
go run .
```

## Verify sign-in and sign-out

1. Open `http://localhost:8080` and select **Sign in with IdRa**. You land on IdRa's sign-in page.
2. Sign in, with MFA if your policies ask for it. You come back to a **Signed in** page showing your claims.
3. Select **Sign out**. You return to **Signed out**. This also ends the IdRa session, so the next sign-in asks for your credentials again.

**Reports → System Log** in the admin console shows the sign-in.

## What the app receives

The ID token doesn't carry `email` or `name`. Read them from UserInfo, as this sample does.

| Claim | ID token | UserInfo | Scope |
| --- | --- | --- | --- |
| `sub` | Yes | Yes | `openid` |
| `preferred_username` (the email), `updated_at` | Yes | Yes | `profile` |
| `name`, `given_name`, `family_name` | No | Yes | `profile` |
| `email`, `email_verified` | No | Yes | `email` |
| `groups` (group names, always including `Everyone`) | Yes | Yes | `groups` |

Add `offline_access` to the scopes to receive a refresh token. Access tokens last an hour. See [OpenID Connect](https://docs.idra.zone/docs/admin/oidc) for the rest.

## Troubleshooting

| What you see | Cause | Fix |
| --- | --- | --- |
| `Use https on a public host, or for local development http on localhost, 127.0.0.1 or [::1] with a port (…)` | The redirect URI is `http` on another host or without a port, or `https` on a loopback host. | Use `http://localhost:8080/callback` locally and `https` on a public host in production. |
| **Sign-in error**: `invalid redirect uri` | The app sent a redirect URI that isn't registered. Matching is exact, including port and path. | Make it match **Sign-in redirect URIs** on the **Sign On** tab. |
| **Access to … denied**: `You are not assigned to this application.` | The person isn't assigned. | Assign them on **Assignments**. |
| **Access to … denied**: `This application is currently inactive.` | The app is deactivated. | Activate it on the app's page. |
| **Access denied**: `This application is not registered with your organization.` | Wrong client ID, or a client ID from another organization. | Copy the **Client ID** from **Sign On**. |
| **Access denied**: `Your organization's sign-on policy does not allow this access.`, URL has `code=invalid_scope` | The app asked for a scope that doesn't exist. | Use `openid`, `profile`, `email`, `groups`, `offline_access` or a scope defined under **Security → API**. |
| `pkce is required for this client` | The authorize request has no PKCE challenge. | Send `code_challenge` with `code_challenge_method=S256`. IdRa accepts only `S256`. |
| `invalid_client`: `invalid client_secret` | Wrong secret, or it was rotated. | Use **Generate new secret** on **Sign On**. |
| `invalid_client`: `client registered for client_secret_basic cannot use none` | A Web Application is running without its secret. | Set `IDRA_CLIENT_SECRET`. |
| `invalid_client`: `client registered for client_secret_basic cannot use client_secret_post` | The library sent the secret in the body. | Send it as HTTP Basic, or change **Client authentication** on **Sign On**. |
| `invalid_grant`: `redirect_uri mismatch` | The token request's redirect URI differs from the authorize request's. | Use the same value in both. |
| `invalid_grant`: `code verification failed` | The PKCE verifier doesn't match the challenge. | Keep the verifier from the authorize step, per sign-in. |
| `404` on discovery | The issuer is missing `/api/auth`. | Use the **Issuer** from **Sign On**: `https://<your-org>.idra.team/api/auth`. |
| Page says `Logged out. The requested post-logout redirect was not registered.` | The person was signed out, but the return URL isn't registered. | Add it to **Sign-out redirect URIs**. |
| **Confirm logout** page | No `id_token_hint`, or it's for a different session than the browser's. | Send the ID token as the hint. Without one, the page is the expected safeguard. |

**Reports → System Log** records refused access as `app.access` with the reason.