Sign In With Google: Why It Matters and How to Add It to WorkingAgents

Why It Matters

Password-based login is a liability. Users reuse passwords, forget them, and pick weak ones. Every password database is a breach waiting to happen. “Sign in with Google” (and equivalents – GitHub, Microsoft, Apple) shifts the authentication burden to an identity provider that has already solved it: hardware keys, phishing-resistant flows, anomaly detection, enforced 2FA, and breach monitoring that no small company can replicate.

For WorkingAgents specifically, the case is stronger. The target customer is a company. Companies run on Google Workspace or Microsoft 365. Their employees already have SSO-managed Google accounts with enforced 2FA. Asking those employees to create and manage a separate WorkingAgents password is friction that has no upside – it adds a weaker credential without improving security.

Practically: fewer support requests, no forgotten passwords, no password reset emails, no credential storage liability. A user clicks one button, Google authenticates them, and WorkingAgents gets a verified identity.


OAuth vs. OpenID Connect – The Actual Difference

This is commonly confused. They solve different problems.

OAuth 2.0 is an authorization protocol. It answers: “Can this application access this resource on your behalf?” The result is an access token that lets the application call an API (e.g., read your Google Drive files). OAuth says nothing about who you are – only what you’re allowed to do.

OpenID Connect (OIDC) is an authentication protocol built on top of OAuth 2.0. It adds an identity layer. The result is an ID token (a signed JWT) that contains claims about the user: who they are, their email, their name, when they authenticated. OIDC answers: “Who is this person?”

“Sign in with Google” is OIDC, not plain OAuth. When a user clicks the button, Google runs an OAuth 2.0 flow internally, but what WorkingAgents receives is an ID token – a cryptographically signed statement from Google saying “this person is [email protected], their name is James, they authenticated at 14:32 UTC.”

WorkingAgents already implements OAuth 2.0 (for MCP client authorization). Adding Google Sign-In means adding OIDC on the inbound side – WorkingAgents becomes the relying party that trusts Google as an identity provider.

OAuth 2.0:   WorkingAgents → issues tokens → MCP clients access WorkingAgents
OpenID Connect: Google → issues ID tokens → WorkingAgents trusts Google's identity claim

The OIDC Flow

Browser                  WorkingAgents              Google
   │                          │                        │
   │── click "Sign in" ──────>│                        │
   │                          │── redirect to Google ─>│
   │                          │   (with nonce, scope)  │
   │<─────────────────────────────── Google login UI ──│
   │── user authenticates ──────────────────────────>  │
   │<─ redirect to /auth/google/callback?code= ────────│
   │── follow redirect ──────>│                        │
   │                          │── POST /token ────────>│
   │                          │<── id_token + access_token
   │                          │── verify JWT signature │
   │                          │── extract email/sub    │
   │                          │── find or create user  │
   │                          │── set session cookie   │
   │<─ redirect to /dashboard─│                        │

The key step is JWT verification. WorkingAgents fetches Google’s public keys from https://www.googleapis.com/oauth2/v3/certs and verifies the id_token signature locally. No round trip to Google per request – just a signature check.

The ID token payload contains:

{
  "sub": "108346...",
  "email": "[email protected]",
  "email_verified": true,
  "name": "James Aspinwall",
  "picture": "https://lh3.googleusercontent.com/...",
  "iss": "https://accounts.google.com",
  "aud": "YOUR_CLIENT_ID",
  "exp": 1773999999,
  "iat": 1773996399,
  "nonce": "abc123..."
}

sub is the stable Google user ID – it never changes even if the user changes their email address. It should be the primary key for linking a Google identity to a WorkingAgents user.


Implementation in WorkingAgents

1. Google Cloud Console Setup

Register the application at console.cloud.google.com:

Add to config/prod.exs:

config :mcp,
  google_client_id: System.get_env("GOOGLE_CLIENT_ID"),
  google_client_secret: System.get_env("GOOGLE_CLIENT_SECRET"),
  google_redirect_uri: "https://workingagents.ai/auth/google/callback"

2. New Routes

In lib/my_mcp_server_router.ex, add two routes alongside the existing /login:

get  "/auth/google",          :google_auth_start
get  "/auth/google/callback", :google_auth_callback

3. Login Button

In lib/login_web.ex, add a button to the existing login form:

<a href="/auth/google" class="btn-google">
  <svg ...google logo...></svg>
  Sign in with Google
</a>

4. Start the Flow – google_auth_start

defp google_auth_start(conn, _params) do
  nonce    = :crypto.strong_rand_bytes(16) |> Base.url_encode64(padding: false)
  state    = :crypto.strong_rand_bytes(16) |> Base.url_encode64(padding: false)

  # Store state + nonce in session to verify on callback
  conn = put_session(conn, :google_state, state)
  conn = put_session(conn, :google_nonce, nonce)

  params = URI.encode_query(%{
    client_id:     Application.get_env(:mcp, :google_client_id),
    redirect_uri:  Application.get_env(:mcp, :google_redirect_uri),
    response_type: "code",
    scope:         "openid email profile",
    state:         state,
    nonce:         nonce,
    access_type:   "online"
  })

  redirect(conn, external: "https://accounts.google.com/o/oauth2/v2/auth?#{params}")
end

5. Handle the Callback – google_auth_callback

defp google_auth_callback(conn, %{"code" => code, "state" => state}) do
  # 1. Verify state matches what we sent
  stored_state = get_session(conn, :google_state)
  if state != stored_state, do: raise "State mismatch -- possible CSRF"

  stored_nonce = get_session(conn, :google_nonce)

  # 2. Exchange code for tokens
  {:ok, tokens} = exchange_google_code(code)
  id_token = tokens["id_token"]

  # 3. Verify and decode the ID token
  {:ok, claims} = verify_google_id_token(id_token, stored_nonce)

  # 4. Find or create user
  email = claims["email"]
  google_sub = claims["sub"]

  user = case User.find_by_google_sub(google_sub) do
    {:ok, u} -> u
    {:error, :not_found} ->
      case User.find_by_email(email) do
        {:ok, u} ->
          # Link existing account
          User.set_google_sub(u.id, google_sub)
          u
        {:error, :not_found} ->
          # Create new account
          {:ok, u} = User.create_from_google(email, claims["name"], google_sub)
          u
      end
  end

  # 5. Issue session cookie (same as password login)
  token = encrypt_user_id(user.id)
  conn
  |> delete_session(:google_state)
  |> delete_session(:google_nonce)
  |> put_resp_cookie("user", token, max_age: 30 * 24 * 3600, http_only: true)
  |> redirect(to: "/")
end

6. Token Exchange

defp exchange_google_code(code) do
  body = URI.encode_query(%{
    code:          code,
    client_id:     Application.get_env(:mcp, :google_client_id),
    client_secret: Application.get_env(:mcp, :google_client_secret),
    redirect_uri:  Application.get_env(:mcp, :google_redirect_uri),
    grant_type:    "authorization_code"
  })

  case Req.post("https://oauth2.googleapis.com/token",
        body: body,
        headers: [{"content-type", "application/x-www-form-urlencoded"}]) do
    {:ok, %{status: 200, body: tokens}} -> {:ok, tokens}
    {:ok, resp} -> {:error, resp.body}
    err -> err
  end
end

7. ID Token Verification

The ID token is a JWT signed with Google’s private key. Verify it using Google’s public keys:

defp verify_google_id_token(id_token, expected_nonce) do
  # Fetch Google's public keys (cache these -- they change infrequently)
  {:ok, %{body: %{"keys" => jwks}}} =
    Req.get("https://www.googleapis.com/oauth2/v3/certs")

  # Decode header to find which key was used
  [header_b64 | _] = String.split(id_token, ".")
  %{"kid" => kid} = header_b64 |> Base.url_decode64!(padding: false) |> Jason.decode!()
  jwk = Enum.find(jwks, &(&1["kid"] == kid))

  # Verify signature and claims using JOSE library
  case JOSE.JWT.verify_strict(JOSE.JWK.from_map(jwk), ["RS256"], id_token) do
    {true, %JOSE.JWT{fields: claims}, _} ->
      client_id = Application.get_env(:mcp, :google_client_id)
      now = System.system_time(:second)

      cond do
        claims["iss"] not in ["https://accounts.google.com", "accounts.google.com"] ->
          {:error, "invalid issuer"}
        claims["aud"] != client_id ->
          {:error, "invalid audience"}
        claims["exp"] < now ->
          {:error, "token expired"}
        claims["nonce"] != expected_nonce ->
          {:error, "nonce mismatch"}
        true ->
          {:ok, claims}
      end

    {false, _, _} ->
      {:error, "invalid signature"}
  end
end

8. User Model Changes

Two additions to the users table:

ALTER TABLE users ADD COLUMN google_sub TEXT UNIQUE;
ALTER TABLE users ADD COLUMN auth_provider TEXT DEFAULT 'password';

google_sub is the stable identifier from Google. auth_provider tracks how the account was created ('password' or 'google'). Users who signed up with Google don’t have a password – password login should be disabled for them.


Security Checklist


What Changes for Users

Users who sign in with Google bypass the password form entirely. The session cookie is identical to a password-based login – the rest of the application sees no difference. Permissions still come from AccessControl. The permission grant flow is unchanged.

For a company deploying WorkingAgents on their own server, they register their own Google OAuth credentials (their Google Cloud project, their client_id). Each WorkingAgents instance has its own Google OAuth app.


Dependency

Add jose to mix.exs for JWT verification:

{:jose, "~> 1.11"}

Req is already in the dependency tree for HTTP calls.