Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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
35 changes: 27 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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`
19 changes: 19 additions & 0 deletions src/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
68 changes: 47 additions & 21 deletions src/routes/api/auth/callback/index.ts
Original file line number Diff line number Diff line change
@@ -1,37 +1,63 @@
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,
cookie,
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, "/");
};
32 changes: 28 additions & 4 deletions src/routes/api/auth/index.ts
Original file line number Diff line number Diff line change
@@ -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));
};
2 changes: 2 additions & 0 deletions src/routes/api/auth/logout/index.ts
Original file line number Diff line number Diff line change
@@ -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, "/");
};
11 changes: 7 additions & 4 deletions src/routes/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,19 +67,22 @@ export default component$(() => {
<p>
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.
</p>
</div>
<div class="card">
<p class="text-center" style="margin-bottom: 0.75rem;">
Sign in with your Google account to get started.
Sign in with your Google or GitHub account to get started.
</p>
<p class="text-center">
<a href="/api/auth" class="btn btn-primary">
<p class="text-center sign-in-actions">
<a href="/api/auth?provider=google" class="btn btn-primary">
Sign in with Google
</a>
<a href="/api/auth?provider=github" class="btn btn-outline">
Sign in with GitHub
</a>
</p>
<p
class="text-center text-muted"
Expand Down
35 changes: 31 additions & 4 deletions src/routes/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,20 @@
import { component$, Slot } from "@builder.io/qwik";
import { routeLoader$ } from "@builder.io/qwik-city";
import { routeLoader$, useLocation } from "@builder.io/qwik-city";

export interface UserSession {
isLoggedIn: boolean;
name: string;
}

const AUTH_ERRORS: Record<string, string> = {
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$<UserSession>(async ({ cookie }) => {
const rawName = cookie.get("user_name")?.value;

Expand All @@ -17,6 +26,8 @@ export const useSession = routeLoader$<UserSession>(async ({ cookie }) => {

export default component$(() => {
const session = useSession();
const loc = useLocation();
const authError = AUTH_ERRORS[loc.url.searchParams.get("error") || ""];

return (
<div class="app">
Expand Down Expand Up @@ -47,14 +58,30 @@ export default component$(() => {
</a>
</div>
) : (
<a href="/api/auth" class="btn btn-small btn-primary">
Sign in with Google
</a>
<div class="sign-in-actions">
<a
href="/api/auth?provider=google"
class="btn btn-small btn-primary"
>
Sign in with Google
</a>
<a
href="/api/auth?provider=github"
class="btn btn-small btn-outline"
>
Sign in with GitHub
</a>
</div>
)}
</div>
</div>
</header>
<main class="main-content">
{authError && (
<div class="container">
<div class="auth-error-banner">{authError}</div>
</div>
)}
<Slot />
</main>
<footer class="app-footer">
Expand Down
Loading