Built for people who think better out loud.

backend: Add OAuth post-auth redirect and session introspection

isaaccorbrey.com cde9a912 75b966d8

verified
+128 -6
+19 -1
README.md
··· 28 28 - `OAUTH_REDIRECT_URI` (e.g. `https://your-app.com/oauth/callback`) 29 29 - `OAUTH_SIGNING_KEY` (did:key private key for client assertions) 30 30 31 + Generate a signing key with the built-in helper: 32 + 33 + ```sh 34 + cd backend 35 + cargo run --bin oauth_keygen 36 + ``` 37 + 38 + Set the output as `OAUTH_SIGNING_KEY`. 39 + 31 40 Optional settings: 32 41 33 42 - `OAUTH_BASE_URL` (if set, derives `OAUTH_CLIENT_ID`, `OAUTH_REDIRECT_URI`, and `OAUTH_JWKS_URI`) ··· 35 44 - `OAUTH_CLIENT_URI` 36 45 - `OAUTH_JWKS_URI` 37 46 - `OAUTH_SCOPES` (defaults to `atproto transition:generic`) 38 - - `OAUTH_POST_AUTH_REDIRECT` (defaults to `/`) 47 + - `OAUTH_POST_AUTH_REDIRECT` (defaults to `OAUTH_BASE_URL` when set, otherwise `/`) 48 + - `OAUTH_POST_AUTH_REDIRECT_ROUTE` (if set, combines with `OAUTH_BASE_URL` when available) 39 49 - `OAUTH_COOKIE_NAME` (defaults to `slipnote_session`) 40 50 - `OAUTH_SESSION_TTL_SECONDS` (defaults to 7 days) 41 51 - `PLC_HOSTNAME` (defaults to `plc.directory`) 52 + 53 + If you do not have a real URL for OAuth callbacks, expose the backend with ngrok: 54 + 55 + ```sh 56 + ngrok http 3001 57 + ``` 58 + 59 + Then set `OAUTH_BASE_URL` to the ngrok HTTPS URL (for example, `https://abc123.ngrok-free.app`). 42 60 43 61 ## Tilt 44 62
+1
Tiltfile
··· 60 60 'backend/Cargo.toml', 61 61 'backend/Cargo.lock', 62 62 'backend/src', 63 + 'backend/.env', 63 64 ], 64 65 links=[ backend_url ], 65 66 resource_deps=[ 'postgres' ],
+15
backend/.env.example
··· 5 5 SLIPNOTE_BIND_ADDR=0.0.0.0:3001 6 6 SLIPNOTE_ENV=local 7 7 SLIPNOTE_TRANSCRIPTION_COST_PER_SECOND_DOLLARS=0.00009434 8 + SLIPNOTE_DISABLE_DOTENV= 9 + DATABASE_URL= 8 10 AXIOM_TOKEN= 9 11 AXIOM_DATASET= 10 12 AXIOM_URL= 13 + OAUTH_BASE_URL= 14 + # OAUTH_CLIENT_ID= # Derived from OAUTH_BASE_URL if set. 15 + # OAUTH_REDIRECT_URI= # Derived from OAUTH_BASE_URL if set. 16 + OAUTH_CLIENT_NAME= 17 + # OAUTH_CLIENT_URI= # Defaults to OAUTH_BASE_URL if set. 18 + # OAUTH_JWKS_URI= # Derived from OAUTH_BASE_URL if set. 19 + OAUTH_SIGNING_KEY= 20 + OAUTH_SCOPES=atproto transition:generic 21 + OAUTH_POST_AUTH_REDIRECT= 22 + OAUTH_POST_AUTH_REDIRECT_ROUTE= 23 + OAUTH_COOKIE_NAME=slipnote_session 24 + OAUTH_SESSION_TTL_SECONDS=604800 25 + PLC_HOSTNAME=plc.directory
+18 -1
backend/src/config.rs
··· 105 105 .as_ref() 106 106 .map(|base| format!("{base}/.well-known/jwks.json")) 107 107 }); 108 + let oauth_post_auth_redirect = env::var("OAUTH_POST_AUTH_REDIRECT").ok(); 109 + let oauth_post_auth_redirect_route = env::var("OAUTH_POST_AUTH_REDIRECT_ROUTE").ok(); 110 + let oauth_post_auth_redirect = oauth_post_auth_redirect.or_else(|| { 111 + if let Some(route) = oauth_post_auth_redirect_route { 112 + if let Some(base) = oauth_base_url.as_ref() { 113 + let trimmed_route = if route.starts_with('/') { 114 + route 115 + } else { 116 + format!("/{route}") 117 + }; 118 + return Some(format!("{base}{trimmed_route}")); 119 + } 120 + return Some(route); 121 + } 122 + oauth_base_url.clone() 123 + }); 108 124 109 125 Ok(Self { 110 126 openai_api_key: require_env("OPENAI_API_KEY")?, ··· 141 157 oauth_jwks_uri, 142 158 oauth_signing_key: require_env("OAUTH_SIGNING_KEY")?, 143 159 oauth_scopes: env_or("OAUTH_SCOPES", "atproto transition:generic"), 144 - oauth_post_auth_redirect: env_or("OAUTH_POST_AUTH_REDIRECT", "/"), 160 + oauth_post_auth_redirect: oauth_post_auth_redirect 161 + .unwrap_or_else(|| "/".to_string()), 145 162 oauth_cookie_name: env_or("OAUTH_COOKIE_NAME", "slipnote_session"), 146 163 oauth_session_ttl_seconds: env_or_i64("OAUTH_SESSION_TTL_SECONDS", 60 * 60 * 24 * 7), 147 164 plc_hostname: env_or("PLC_HOSTNAME", "plc.directory"),
+22 -4
backend/src/main.rs
··· 1 1 use sqlx::PgPool; 2 2 use tokio::net::TcpListener; 3 - use tower_http::cors::{Any, CorsLayer}; 3 + use tower_http::cors::{AllowOrigin, CorsLayer}; 4 4 5 5 mod config; 6 6 mod logging; ··· 44 44 oauth_signing_key: oauth_dependencies.oauth_signing_key, 45 45 }; 46 46 let app = if cfg!(debug_assertions) { 47 + let allowed_origins = [ 48 + "http://localhost:4321", 49 + "http://localhost:6007", 50 + ] 51 + .into_iter() 52 + .filter_map(|origin| origin.parse().ok()) 53 + .collect::<Vec<_>>(); 54 + let allowed_headers = [ 55 + axum::http::header::ACCEPT, 56 + axum::http::header::AUTHORIZATION, 57 + axum::http::header::CONTENT_TYPE, 58 + ]; 59 + let allowed_methods = [ 60 + axum::http::Method::GET, 61 + axum::http::Method::POST, 62 + axum::http::Method::OPTIONS, 63 + ]; 47 64 routers::router(app_state).layer( 48 65 CorsLayer::new() 49 - .allow_origin(Any) 50 - .allow_methods(Any) 51 - .allow_headers(Any), 66 + .allow_origin(AllowOrigin::list(allowed_origins)) 67 + .allow_methods(allowed_methods) 68 + .allow_headers(allowed_headers) 69 + .allow_credentials(true), 52 70 ) 53 71 } else { 54 72 routers::router(app_state)
+53
backend/src/routers/auth.rs
··· 36 36 subject: Option<String>, 37 37 } 38 38 39 + #[derive(Serialize)] 40 + struct SessionUserResponse { 41 + user_id: String, 42 + did: String, 43 + handle: Option<String>, 44 + } 45 + 39 46 pub fn router() -> Router<AppState> { 40 47 Router::<AppState>::new() 41 48 .route("/oauth/client-metadata.json", get(handle_oauth_metadata)) 42 49 .route("/.well-known/jwks.json", get(handle_oauth_jwks)) 43 50 .route("/oauth/callback", get(oauth_callback)) 44 51 .route("/api/auth/atproto/start", get(start_oauth)) 52 + .route("/api/auth/me", get(current_user)) 45 53 .route("/api/auth/logout", post(logout)) 46 54 } 47 55 ··· 298 306 ); 299 307 300 308 Ok((headers, Redirect::to(&state.config.oauth_post_auth_redirect))) 309 + } 310 + 311 + async fn current_user( 312 + State(state): State<AppState>, 313 + headers: HeaderMap, 314 + ) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> { 315 + let session_id = read_session_cookie(&headers, &state.config.oauth_cookie_name).ok_or_else( 316 + || error_response(StatusCode::UNAUTHORIZED, "Missing session cookie."), 317 + )?; 318 + 319 + let record = sqlx::query( 320 + r#" 321 + SELECT users.id, users.did, users.handle 322 + FROM sessions 323 + JOIN users ON users.id = sessions.user_id 324 + WHERE sessions.id = $1 325 + AND sessions.expires_at > NOW() 326 + "#, 327 + ) 328 + .bind(session_id) 329 + .fetch_optional(&state.db_pool) 330 + .await 331 + .map_err(|err| { 332 + error_response( 333 + StatusCode::INTERNAL_SERVER_ERROR, 334 + &format!("Failed to load session user. {err}"), 335 + ) 336 + })? 337 + .ok_or_else(|| error_response(StatusCode::UNAUTHORIZED, "Session not found."))?; 338 + 339 + let user_id: Uuid = record 340 + .try_get("id") 341 + .map_err(|err| error_response(StatusCode::INTERNAL_SERVER_ERROR, &format!("{err}")))?; 342 + let did: String = record 343 + .try_get("did") 344 + .map_err(|err| error_response(StatusCode::INTERNAL_SERVER_ERROR, &format!("{err}")))?; 345 + let handle: Option<String> = record 346 + .try_get("handle") 347 + .map_err(|err| error_response(StatusCode::INTERNAL_SERVER_ERROR, &format!("{err}")))?; 348 + 349 + Ok(Json(SessionUserResponse { 350 + user_id: user_id.to_string(), 351 + did, 352 + handle, 353 + })) 301 354 } 302 355 303 356 async fn logout(State(state): State<AppState>, headers: HeaderMap) -> impl IntoResponse {