diff --git a/.env.example b/.env.example index d2dfd24..cf66fa1 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,10 @@ GOOGLE_CLIENT_ID=your-google-oauth-client-id GOOGLE_CLIENT_SECRET=your-google-oauth-client-secret GOOGLE_REDIRECT_URI=http://localhost:5173/api/auth/callback +GITHUB_CLIENT_ID=your-github-oauth-client-id +GITHUB_CLIENT_SECRET=your-github-oauth-client-secret +GITHUB_REDIRECT_URI=http://localhost:5173/api/auth/callback +GITHUB_ORG=BrandEmbassy PUBLIC_SPACETIMEDB_MODULE= PUBLIC_SPACETIMEDB_URI=wss://maincloud.spacetimedb.com API_KEYS=sk-agent-your-key-here diff --git a/README.md b/README.md index 18cecae..c712f9f 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ Web app for reserving office parking spots, backed by [SpacetimeDB](https://spac - Node.js `^18.17.0 || ^20.3.0 || >=21.0.0` - A Google Cloud project with OAuth 2.0 credentials +- Optionally a GitHub OAuth app, to offer GitHub sign-in as well - SpacetimeDB table ## Environment Variables @@ -16,13 +17,30 @@ Copy the example file and fill in your credentials: cp .env.example .env ``` -| Variable | Description | Required | -| --------------------------- | -------------------------------------------- | -------- | -| `GOOGLE_CLIENT_ID` | Google OAuth 2.0 Client ID | Yes | -| `GOOGLE_CLIENT_SECRET` | Google OAuth 2.0 Client Secret | Yes | -| `PUBLIC_SPACETIMEDB_MODULE` | Database name | Yes | -| `PUBLIC_SPACETIMEDB_URI` | Database cluster | Yes | -| `API_KEYS` | Comma-separated API keys for REST API access | No | +| Variable | Description | Required | +| --------------------------- | ----------------------------------------------------- | -------- | +| `GOOGLE_CLIENT_ID` | Google OAuth 2.0 Client ID | Yes | +| `GOOGLE_CLIENT_SECRET` | Google OAuth 2.0 Client Secret | Yes | +| `GOOGLE_REDIRECT_URI` | Callback URL, defaults to the localhost one | No | +| `GITHUB_CLIENT_ID` | GitHub OAuth app Client ID | No | +| `GITHUB_CLIENT_SECRET` | GitHub OAuth app Client Secret | No | +| `GITHUB_REDIRECT_URI` | Callback URL, defaults to the localhost one | No | +| `GITHUB_ORG` | GitHub org whose members may sign in (`BrandEmbassy`) | No | +| `PUBLIC_SPACETIMEDB_MODULE` | Database name | Yes | +| `PUBLIC_SPACETIMEDB_URI` | Database cluster | Yes | +| `API_KEYS` | Comma-separated API keys for REST API access | No | + +## Sign-in + +Users sign in with Google or with GitHub; both providers land on the same +`/api/auth/callback` route and the provider is chosen with `/api/auth?provider=github`. +GitHub sign-in is restricted to **active members of the `GITHUB_ORG` organization** +(`BrandEmbassy` by default) — everyone else is rejected. Leave the `GITHUB_*` +variables unset and the GitHub button simply will not work. + +Reservations are keyed by the display name the provider returns (`name`, falling back +to the `@login` for GitHub accounts with no name set), so signing in through a +different provider than usual can produce a different name. ## Project Structure @@ -129,4 +147,5 @@ A REST API (`/api/v1`) is available for AI agent and programmatic access. Authen - Set `ORIGIN` to your actual domain (e.g. `https://parking.example.com`) -- required for CSRF protection - Update `GOOGLE_REDIRECT_URI` in your Google Cloud Console to match your production callback URL (`https://your-domain.com/api/auth/callback`) -- Ensure outbound HTTPS access to `accounts.google.com` and `googleapis.com` +- Set `GITHUB_REDIRECT_URI` to the same callback URL and register it as the Authorization callback URL of your GitHub OAuth app +- Ensure outbound HTTPS access to `accounts.google.com`, `googleapis.com`, `github.com` and `api.github.com` diff --git a/src/global.css b/src/global.css index da8148e..3976c2e 100644 --- a/src/global.css +++ b/src/global.css @@ -128,6 +128,25 @@ a:hover { color: var(--color-text); } +.sign-in-actions { + display: flex; + align-items: center; + justify-content: center; + flex-wrap: wrap; + gap: 0.5rem; +} + +.auth-error-banner { + background: #fee2e2; + border: 1px solid var(--color-danger); + border-radius: var(--radius); + padding: 0.75rem 1rem; + color: var(--color-danger); + font-size: 0.875rem; + font-weight: 500; + margin-bottom: 1rem; +} + .main-content { flex: 1; padding: 1.5rem 1rem 3rem; diff --git a/src/routes/api/auth/callback/index.ts b/src/routes/api/auth/callback/index.ts index fe556d1..5747744 100644 --- a/src/routes/api/auth/callback/index.ts +++ b/src/routes/api/auth/callback/index.ts @@ -1,5 +1,6 @@ import type { RequestHandler } from "@builder.io/qwik-city"; -import { getTokensFromCode, getUserInfo } from "~/services/auth"; +import { PROVIDERS, type OAuthUser } from "~/services/auth"; +import { parseState, STATE_COOKIE_NAME } from "~/services/oauth-state"; export const onGet: RequestHandler = async ({ query, @@ -7,31 +8,56 @@ export const onGet: RequestHandler = async ({ redirect, env, }) => { + const stateCookie = cookie.get(STATE_COOKIE_NAME)?.value; + cookie.delete(STATE_COOKIE_NAME, { path: "/" }); + + const providerId = parseState(stateCookie, query.get("state")); + if (!providerId) { + throw redirect(302, "/?error=invalid_state"); + } + const code = query.get("code"); if (!code) { - throw redirect(302, "/"); + throw redirect(302, "/?error=auth_failed"); } - const tokens = await getTokensFromCode(env, code); - - if (tokens.access_token) { - // Short-lived access token (used only to fetch user name at login) - cookie.set("access_token", tokens.access_token, { - path: "/", - httpOnly: true, - sameSite: "lax", - maxAge: 3600, - }); - - // Get user info and store name (long-lived, used to identify reservations) - const user = await getUserInfo(tokens.access_token); - cookie.set("user_name", encodeURIComponent(user.name), { - path: "/", - httpOnly: false, - sameSite: "lax", - maxAge: 60 * 60 * 24 * 30, - }); + const provider = PROVIDERS[providerId]; + + let accessToken: string | undefined; + let user: OAuthUser | null = null; + try { + accessToken = (await provider.getTokensFromCode(env, code)).access_token; + if (accessToken) { + // Also checks the provider allows this account to use the app + user = await provider.getUserInfo(env, accessToken); + } + } catch { + // The provider could not answer — not the same as a rejected account + throw redirect(302, "/?error=auth_failed"); } + if (!accessToken) { + throw redirect(302, "/?error=auth_failed"); + } + if (!user) { + throw redirect(302, "/?error=not_authorized"); + } + + // Short-lived access token (used only to fetch user name at login) + cookie.set("access_token", accessToken, { + path: "/", + httpOnly: true, + sameSite: "lax", + maxAge: 3600, + }); + + // Store name (long-lived, used to identify reservations) + cookie.set("user_name", encodeURIComponent(user.name), { + path: "/", + httpOnly: false, + sameSite: "lax", + maxAge: 60 * 60 * 24 * 30, + }); + throw redirect(302, "/"); }; diff --git a/src/routes/api/auth/index.ts b/src/routes/api/auth/index.ts index 2dafd19..5ea7836 100644 --- a/src/routes/api/auth/index.ts +++ b/src/routes/api/auth/index.ts @@ -1,7 +1,31 @@ import type { RequestHandler } from "@builder.io/qwik-city"; -import { getAuthUrl } from "~/services/auth"; +import { isProviderId, PROVIDERS } from "~/services/auth"; +import { + createState, + STATE_COOKIE_MAX_AGE, + STATE_COOKIE_NAME, +} from "~/services/oauth-state"; -export const onGet: RequestHandler = async ({ redirect, env }) => { - const authUrl = getAuthUrl(env); - throw redirect(302, authUrl); +export const onGet: RequestHandler = async ({ + query, + cookie, + redirect, + env, +}) => { + const requested = query.get("provider"); + const providerId = isProviderId(requested) ? requested : "google"; + + if (!PROVIDERS[providerId].isConfigured(env)) { + throw redirect(302, "/?error=provider_not_configured"); + } + + const { state, cookieValue } = createState(providerId); + cookie.set(STATE_COOKIE_NAME, cookieValue, { + path: "/", + httpOnly: true, + sameSite: "lax", + maxAge: STATE_COOKIE_MAX_AGE, + }); + + throw redirect(302, PROVIDERS[providerId].getAuthUrl(env, state)); }; diff --git a/src/routes/api/auth/logout/index.ts b/src/routes/api/auth/logout/index.ts index 0ceb68c..9a4fa25 100644 --- a/src/routes/api/auth/logout/index.ts +++ b/src/routes/api/auth/logout/index.ts @@ -1,9 +1,11 @@ import type { RequestHandler } from "@builder.io/qwik-city"; +import { STATE_COOKIE_NAME } from "~/services/oauth-state"; export const onGet: RequestHandler = async ({ cookie, redirect }) => { cookie.delete("access_token", { path: "/" }); cookie.delete("refresh_token", { path: "/" }); cookie.delete("user_name", { path: "/" }); cookie.delete("user_email", { path: "/" }); + cookie.delete(STATE_COOKIE_NAME, { path: "/" }); throw redirect(302, "/"); }; diff --git a/src/routes/index.tsx b/src/routes/index.tsx index aab6eb0..0ad7e38 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -67,19 +67,22 @@ export default component$(() => {
NiCE Prague Parking is an internal tool for the Prague office which allows employees to view and reserve available parking spaces for - the day. Sign in with your Google account to see today's + the day. Sign in with your Google or GitHub account to see today's availability in real time, claim a specific spot, or use Quick Reserve to grab the first free one instantly.
- Sign in with your Google account to get started. + Sign in with your Google or GitHub account to get started.
-
-
+
+
Sign in with Google
+
+ Sign in with GitHub
+
= {
+ not_authorized:
+ "That account is not an active member of the organization allowed to use this app.",
+ provider_not_configured:
+ "That sign-in method is not configured on this server.",
+ invalid_state: "Sign-in failed, please try again.",
+ auth_failed: "Sign-in failed, please try again.",
+};
+
export const useSession = routeLoader$