diff --git a/.claude/deploy-memory.md b/.claude/deploy-memory.md index f40325d..51704fe 100644 --- a/.claude/deploy-memory.md +++ b/.claude/deploy-memory.md @@ -87,9 +87,30 @@ kubectl --kubeconfig ~/dev/perso/proxmox/k3s/kubeconfig.yaml \ --from-literal=RESEND_API_KEY=... \ --from-literal=MISTRAL_API_KEY=... \ --from-literal=REDIS_PASSWORD="" \ + --from-literal=GOOGLE_CLIENT_ID=... \ + --from-literal=GOOGLE_CLIENT_SECRET=... \ --dry-run=client -o yaml | kubectl apply -f - ``` +### Google SSO — setup Google Cloud Console +Si la clé OAuth est perdue ou qu'on doit la régénérer : +1. https://console.cloud.google.com/apis/credentials → projet courant +2. **Create Credentials** → **OAuth client ID** → type **Web application** +3. **Authorized JavaScript origins** : + - `https://app.rubis.arthurbarre.fr` + - `http://localhost:5173` (dev SPA) +4. **Authorized redirect URIs** : + - `https://app.rubis.arthurbarre.fr/api/v1/auth/google/callback` + - `http://localhost:3333/api/v1/auth/google/callback` (dev API) +5. Copier `Client ID` + `Client secret` → mettre dans `apps/api/.env` (dev) + et `rubis-app-secrets` (prod, snippet ci-dessus). + +L'écran de consentement OAuth doit être configuré au moins en mode "Testing" +avec l'email du user courant ajouté en testeur. Pour la prod (n'importe qui +peut se connecter), il faut passer en "In production" (vérification Google +si scopes sensibles ; les nôtres `userinfo.email` + `userinfo.profile` sont +non-sensibles, validation auto). + ### Mise à jour Push git → un (ou les deux) workflow(s) CI se déclenchent selon les paths modifiés. Build+rollout indépendants. diff --git a/apps/api/.env.example b/apps/api/.env.example index 6c53faa..11c5a2f 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -77,4 +77,14 @@ ACCESS_TOKEN_TTL_MINUTES=30 REFRESH_TOKEN_TTL_DAYS=30 COOKIE_DOMAIN= COOKIE_SECURE=false + +#-------------------------------------------------------------------- +# Google SSO (Ally) — créer un OAuth Client ID web sur Google Cloud +# Console, puis ajouter les redirect URIs : +# - http://localhost:3333/api/v1/auth/google/callback (dev) +# - https://app.rubis.arthurbarre.fr/api/v1/auth/google/callback (prod) +#-------------------------------------------------------------------- +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +GOOGLE_CALLBACK_URL=http://localhost:3333/api/v1/auth/google/callback LIMITER_STORE=redis diff --git a/apps/api/adonisrc.ts b/apps/api/adonisrc.ts index 54324b2..5281eba 100644 --- a/apps/api/adonisrc.ts +++ b/apps/api/adonisrc.ts @@ -59,7 +59,8 @@ export default defineConfig({ () => import('@adonisjs/limiter/limiter_provider'), () => import('@adonisjs/mail/mail_provider'), () => import('@adonisjs/drive/drive_provider'), - () => import('@adonisjs/static/static_provider') + () => import('@adonisjs/static/static_provider'), + () => import('@adonisjs/ally/ally_provider'), ], /* diff --git a/apps/api/app/controllers/auth_google_controller.ts b/apps/api/app/controllers/auth_google_controller.ts new file mode 100644 index 0000000..ce5e110 --- /dev/null +++ b/apps/api/app/controllers/auth_google_controller.ts @@ -0,0 +1,128 @@ +import User from '#models/user' +import Organization from '#models/organization' +import { provisionDefaultPlans } from '#services/default_plans' +import { issueRefreshToken } from '#services/refresh_token' +import env from '#start/env' +import db from '@adonisjs/lucid/services/db' +import logger from '@adonisjs/core/services/logger' +import type { HttpContext } from '@adonisjs/core/http' +import crypto from 'node:crypto' + +/** + * Google SSO via @adonisjs/ally. + * + * Flow : + * 1. Le SPA navigate vers GET /api/v1/auth/google/redirect + * 2. Ally redirige vers Google avec state + scopes + * 3. Google redirige vers GET /api/v1/auth/google/callback?code=... + * 4. Backend matche/crée l'user, pose le refresh cookie (httpOnly) + * 5. Redirige le browser vers le SPA sur /auth/google/complete?next=... + * 6. Le SPA appelle POST /api/v1/auth/refresh (cookie auto-envoyé) → reçoit + * un access token, navigue vers `next`. + * + * On NE retourne PAS d'access token en JSON ici car la callback est un + * redirect server-side : pas de body lisible par le SPA. + */ +export default class AuthGoogleController { + /** + * GET /api/v1/auth/google/redirect — entrée du flow OAuth. + * Le bouton "Continuer avec Google" pointe directement ici (pas un fetch). + */ + async redirect(ctx: HttpContext) { + return ctx.ally.use('google').redirect() + } + + /** + * GET /api/v1/auth/google/callback — retour de Google. + * + * Stratégie de matching : + * 1. Existing user with `google_id` ? → log in + * 2. Existing user with same `email` ? → link `google_id` + log in + * 3. New user → crée org + plans par défaut + user (password null) + */ + async callback(ctx: HttpContext) { + const google = ctx.ally.use('google') + const webUrl = env.get('WEB_URL', 'http://localhost:5173') + + // Erreurs côté Google (canceled, mismatch, etc.) + if (google.accessDenied()) { + return ctx.response.redirect(`${webUrl}/login?google=denied`) + } + if (google.stateMisMatch()) { + return ctx.response.redirect(`${webUrl}/login?google=state_mismatch`) + } + if (google.hasError()) { + logger.warn({ err: google.getError() }, 'google sso error') + return ctx.response.redirect(`${webUrl}/login?google=error`) + } + + const googleUser = await google.user() + if (!googleUser.email) { + logger.warn({ id: googleUser.id }, 'google sso : email manquant') + return ctx.response.redirect(`${webUrl}/login?google=no_email`) + } + + // 1. Lookup par google_id (canonique) + let user = await User.findBy('googleId', googleUser.id) + let isNewUser = false + + // 2. Fallback email — première connexion d'un user email/password + // via Google, on lie son google_id à son compte existant. + if (!user) { + user = await User.findBy('email', googleUser.email.toLowerCase()) + if (user) { + user.googleId = googleUser.id + if (!user.fullName && googleUser.name) { + user.fullName = googleUser.name + } + await user.save() + } + } + + // 3. Création + if (!user) { + isNewUser = true + user = await db.transaction(async (trx) => { + const org = await Organization.create({ name: '' }, { client: trx }) + await provisionDefaultPlans(org.id, trx) + return User.create( + { + email: googleUser.email!.toLowerCase(), + fullName: googleUser.name ?? null, + // password requis dans le schéma Lucid (string), mais nullable + // en DB. On stocke un random unguessable que personne ne peut + // utiliser pour login email/password (User.verifyCredentials + // hash-comparera et échouera). Le user pourra plus tard + // activer email/password via "mot de passe oublié". + password: crypto.randomBytes(48).toString('base64url'), + googleId: googleUser.id, + organizationId: org.id, + }, + { client: trx } + ) + }) + } + + // Pose le refresh cookie httpOnly (path /api/v1/auth) + await issueRefreshToken(user, ctx) + + // Décide où renvoyer l'utilisateur. + // Org name vide = onboarding entreprise pas terminé → on saute "compte" + // (Google nous a déjà donné nom + email) et on enchaîne sur entreprise. + let next = '/' + if (isNewUser) { + next = '/onboarding/entreprise' + } else { + const org = user.organizationId + ? await Organization.find(user.organizationId) + : null + if (!org || !org.name) { + next = '/onboarding/entreprise' + } else if (!user.signature) { + next = '/onboarding/signature' + } + } + + return ctx.response.redirect(`${webUrl}/auth/google/complete?next=${encodeURIComponent(next)}`) + } +} diff --git a/apps/api/config/ally.ts b/apps/api/config/ally.ts new file mode 100644 index 0000000..0c3426a --- /dev/null +++ b/apps/api/config/ally.ts @@ -0,0 +1,36 @@ +import env from '#start/env' +import { defineConfig, services } from '@adonisjs/ally' + +/** + * Configuration des providers OAuth (Ally). + * + * V1 : Google uniquement (cf. CLAUDE.md → Auth). Les autres viendront + * plus tard si pertinent (Microsoft pour les TPE qui utilisent O365 ?). + * + * Le callback URL pointe vers l'API en interne (/api/v1/auth/google/callback). + * En prod, le reverse proxy nginx (rubis-web) achemine /api/* vers ce + * service, donc la même URL fonctionne pour le browser et pour Google. + */ +const allyConfig = defineConfig({ + google: services.google({ + clientId: env.get('GOOGLE_CLIENT_ID', ''), + clientSecret: env.get('GOOGLE_CLIENT_SECRET', ''), + callbackUrl: env.get( + 'GOOGLE_CALLBACK_URL', + 'http://localhost:3333/api/v1/auth/google/callback' + ), + // Scopes minimaux : on a juste besoin de l'email + nom + photo (avatar + // optionnel V2). Pas de Drive/Calendar : on ne touche pas aux données + // Google de l'utilisateur, on s'en sert juste comme provider d'identité. + scopes: ['userinfo.email', 'userinfo.profile'], + prompt: 'select_account', + }), +}) + +export default allyConfig + +declare module '@adonisjs/ally/types' { + interface SocialProviders extends InferSocialProviders {} +} + +import type { InferSocialProviders } from '@adonisjs/ally/types' diff --git a/apps/api/database/migrations/1778080001300_add_google_sso_to_users_table.ts b/apps/api/database/migrations/1778080001300_add_google_sso_to_users_table.ts new file mode 100644 index 0000000..f47ef31 --- /dev/null +++ b/apps/api/database/migrations/1778080001300_add_google_sso_to_users_table.ts @@ -0,0 +1,28 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +/** + * Ajoute le support Google SSO : + * - `google_id` : sub OAuth Google (stable, unique). On match les retours + * d'auth dessus en priorité, fallback email si non rempli (cas d'un user + * email/password qui ajoute Google plus tard). + * - Rend `password` nullable : un user créé via Google n'a pas de password + * en base. Il pourra en définir un plus tard via "mot de passe oublié". + */ +export default class extends BaseSchema { + protected tableName = 'users' + + async up() { + this.schema.alterTable(this.tableName, (table) => { + table.string('google_id', 64).nullable().unique() + table.string('password').nullable().alter() + }) + } + + async down() { + this.schema.alterTable(this.tableName, (table) => { + table.dropColumn('google_id') + // On ne re-passe pas password à NOT NULL en down — risquerait de + // casser les rows SSO existantes. + }) + } +} diff --git a/apps/api/database/schema.ts b/apps/api/database/schema.ts index f6945a0..fc7cdd4 100644 --- a/apps/api/database/schema.ts +++ b/apps/api/database/schema.ts @@ -302,7 +302,7 @@ export class RelanceTaskSchema extends BaseModel { } export class UserSchema extends BaseModel { - static $columns = ['createdAt', 'email', 'fullName', 'id', 'organizationId', 'password', 'signature', 'updatedAt'] as const + static $columns = ['createdAt', 'email', 'fullName', 'googleId', 'id', 'organizationId', 'password', 'signature', 'updatedAt'] as const $columns = UserSchema.$columns @column.dateTime({ autoCreate: true }) declare createdAt: DateTime @@ -310,12 +310,14 @@ export class UserSchema extends BaseModel { declare email: string @column() declare fullName: string | null + @column() + declare googleId: string | null @column({ isPrimary: true }) declare id: string @column() declare organizationId: string | null @column({ serializeAs: null }) - declare password: string + declare password: string | null @column() declare signature: string | null @column.dateTime({ autoCreate: true, autoUpdate: true }) diff --git a/apps/api/package.json b/apps/api/package.json index 68efef0..eb3401a 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -59,6 +59,7 @@ "youch": "^4.1.1" }, "dependencies": { + "@adonisjs/ally": "^6.3.0", "@adonisjs/auth": "^10.1.0", "@adonisjs/bouncer": "^4.0.0", "@adonisjs/core": "^7.3.1", diff --git a/apps/api/start/env.ts b/apps/api/start/env.ts index 73b58c0..570cffd 100644 --- a/apps/api/start/env.ts +++ b/apps/api/start/env.ts @@ -68,6 +68,11 @@ export default await Env.create(new URL('../', import.meta.url), { COOKIE_DOMAIN: Env.schema.string.optional(), COOKIE_SECURE: Env.schema.boolean.optional(), + // Google SSO (Ally) + GOOGLE_CLIENT_ID: Env.schema.string.optional(), + GOOGLE_CLIENT_SECRET: Env.schema.string.optional(), + GOOGLE_CALLBACK_URL: Env.schema.string.optional({ format: 'url', tld: false }), + /* |---------------------------------------------------------- | Variables for configuring the limiter package diff --git a/apps/api/start/routes.ts b/apps/api/start/routes.ts index e7dd191..4594beb 100644 --- a/apps/api/start/routes.ts +++ b/apps/api/start/routes.ts @@ -30,12 +30,19 @@ router /** * Auth — public. /refresh utilise le cookie httpOnly `rubis_refresh` * posé par signup/login pour émettre une nouvelle AuthSession. + * /google/* → SSO via Ally, callback pose aussi le refresh cookie. */ router .group(() => { router.post('signup', [controllers.NewAccount, 'store']).as('signup') router.post('login', [controllers.AccessTokens, 'store']).as('login') router.post('refresh', [controllers.Refresh, 'handle']).as('refresh') + router + .get('google/redirect', [controllers.AuthGoogle, 'redirect']) + .as('google.redirect') + router + .get('google/callback', [controllers.AuthGoogle, 'callback']) + .as('google.callback') }) .prefix('auth') .as('auth') diff --git a/apps/web/src/components/auth/GoogleButton.tsx b/apps/web/src/components/auth/GoogleButton.tsx new file mode 100644 index 0000000..8309ee9 --- /dev/null +++ b/apps/web/src/components/auth/GoogleButton.tsx @@ -0,0 +1,77 @@ +import { cn } from "@/lib/utils"; + +/** + * Bouton "Continuer avec Google". + * + * IMPORTANT — c'est un ``, PAS un bouton fetch : + * OAuth nécessite un full-page redirect (le browser doit naviguer vers + * l'écran de consentement Google). Un fetch ne peut pas suivre les + * redirections cross-origin avec cookies. + * + * L'URL est relative — nginx (rubis-web) proxy /api/* vers rubis-api, + * donc même origine pour le browser → cookie refresh posé par la + * callback est lisible côté SPA. + */ +export function GoogleButton({ + label = "Continuer avec Google", + className, +}: { + label?: string; + className?: string; +}) { + return ( + + + ); +} + +/** Logo Google officiel — 4 couleurs, taille fixe 18px. */ +function GoogleLogo(props: React.SVGProps) { + return ( + + + + + + + ); +} + +/** Séparateur "ou" entre le bouton SSO et le formulaire email/password. */ +export function AuthDivider({ label = "ou" }: { label?: string }) { + return ( +
+
+ + {label} + +
+
+ ); +} diff --git a/apps/web/src/routes/auth.google.complete.tsx b/apps/web/src/routes/auth.google.complete.tsx new file mode 100644 index 0000000..09f8553 --- /dev/null +++ b/apps/web/src/routes/auth.google.complete.tsx @@ -0,0 +1,69 @@ +import { useEffect, useRef } from "react"; +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { z } from "zod"; +import { toast } from "sonner"; + +import type { AuthSession } from "@rubis/shared"; +import { api } from "@/lib/api"; +import { authStore } from "@/lib/auth"; +import { Gem } from "@/components/brand/Gem"; + +/** + * Callback Google côté SPA — se charge : + * 1. d'appeler POST /api/v1/auth/refresh (le cookie httpOnly posé par + * /api/v1/auth/google/callback est auto-envoyé) + * 2. de stocker l'access token + user dans authStore + * 3. de naviguer vers `?next=...` (envoyé par le backend selon l'état + * d'onboarding : "/" pour user complet, "/onboarding/entreprise" + * pour un nouveau) + * + * En cas d'échec du refresh, on renvoie sur /login avec un toast. + */ +const searchSchema = z.object({ + next: z.string().default("/"), +}); + +export const Route = createFileRoute("/auth/google/complete")({ + validateSearch: searchSchema, + component: GoogleCompletePage, +}); + +function GoogleCompletePage() { + const { next } = Route.useSearch(); + const navigate = useNavigate(); + // Strict-mode protect : avoid double-firing the refresh in dev. + const triggered = useRef(false); + + useEffect(() => { + if (triggered.current) return; + triggered.current = true; + (async () => { + try { + const session = await api.post( + "/api/v1/auth/refresh", + undefined, + { anonymous: true }, + ); + authStore.setSession(session.accessToken, session.user); + const firstName = session.user.fullName?.split(" ")[0]; + toast.success(firstName ? `Bonjour ${firstName}.` : "Connecté."); + // Sécurité : si `next` n'est pas un chemin relatif, on renvoie sur "/". + const target = next.startsWith("/") && !next.startsWith("//") ? next : "/"; + void navigate({ to: target }); + } catch { + toast.error("Connexion Google échouée. Réessayez."); + void navigate({ to: "/login" }); + } + })(); + }, [next, navigate]); + + // UI minimale pendant l'aller-retour réseau (max 1-2s en pratique). + return ( +
+
+ +

Connexion en cours…

+
+
+ ); +} diff --git a/apps/web/src/routes/login.tsx b/apps/web/src/routes/login.tsx index 74fa48a..71429cb 100644 --- a/apps/web/src/routes/login.tsx +++ b/apps/web/src/routes/login.tsx @@ -1,3 +1,4 @@ +import { useEffect } from "react"; import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; import { useForm } from "@tanstack/react-form"; import { useMutation } from "@tanstack/react-query"; @@ -15,11 +16,20 @@ import { Field } from "@/components/ui/Field"; import { Eyebrow } from "@/components/ui/Eyebrow"; import { Brand } from "@/components/brand/Brand"; import { Gem } from "@/components/brand/Gem"; +import { GoogleButton, AuthDivider } from "@/components/auth/GoogleButton"; const searchSchema = z.object({ redirect: z.string().optional(), + google: z.enum(["denied", "state_mismatch", "error", "no_email"]).optional(), }); +const GOOGLE_ERROR_MESSAGES: Record = { + denied: "Connexion Google annulée.", + state_mismatch: "Session expirée, réessayez la connexion Google.", + error: "Connexion Google impossible. Réessayez dans un instant.", + no_email: "Votre compte Google n'a pas d'email associé.", +}; + export const Route = createFileRoute("/login")({ validateSearch: searchSchema, component: LoginPage, @@ -29,6 +39,13 @@ function LoginPage() { const navigate = useNavigate(); const search = Route.useSearch(); + // Toast d'erreur si on revient d'un échec Google SSO (?google=denied|...). + useEffect(() => { + if (search.google && GOOGLE_ERROR_MESSAGES[search.google]) { + toast.error(GOOGLE_ERROR_MESSAGES[search.google]!); + } + }, [search.google]); + const loginMutation = useMutation({ mutationFn: async (input: LoginInput) => api.post("/api/v1/auth/login", input, { anonymous: true }), @@ -121,13 +138,18 @@ function LoginPage() {

+
+ + +
+
{ e.preventDefault(); void form.handleSubmit(); }} - className="mt-7 flex flex-col gap-5" + className="flex flex-col gap-5" > {(field) => ( diff --git a/apps/web/src/routes/signup.tsx b/apps/web/src/routes/signup.tsx index 6f87b70..6281843 100644 --- a/apps/web/src/routes/signup.tsx +++ b/apps/web/src/routes/signup.tsx @@ -19,6 +19,7 @@ import { Card } from "@/components/ui/Card"; import { Eyebrow } from "@/components/ui/Eyebrow"; import { Brand } from "@/components/brand/Brand"; import { Gem } from "@/components/brand/Gem"; +import { GoogleButton, AuthDivider } from "@/components/auth/GoogleButton"; export const Route = createFileRoute("/signup")({ component: SignupPage, @@ -127,13 +128,18 @@ function SignupPage() {

+
+ + +
+ { e.preventDefault(); void form.handleSubmit(); }} - className="mt-7 flex flex-col gap-5" + className="flex flex-col gap-5" > {(field) => ( diff --git a/k3s/app/api.yml b/k3s/app/api.yml index 67f439c..114c667 100644 --- a/k3s/app/api.yml +++ b/k3s/app/api.yml @@ -130,3 +130,8 @@ data: ACCESS_TOKEN_TTL_MINUTES: '30' REFRESH_TOKEN_TTL_DAYS: '30' + + # Google SSO — GOOGLE_CLIENT_ID/SECRET sont dans rubis-app-secrets. + # Le callback URL doit matcher EXACTEMENT ce qui est configuré dans + # Google Cloud Console (OAuth Client → Authorized redirect URIs). + GOOGLE_CALLBACK_URL: 'https://app.rubis.arthurbarre.fr/api/v1/auth/google/callback' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 08c6eae..943f21a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: apps/api: dependencies: + '@adonisjs/ally': + specifier: ^6.3.0 + version: 6.3.0(@adonisjs/assembler@8.4.0(typescript@6.0.3))(@adonisjs/core@7.3.2(@adonisjs/assembler@8.4.0(typescript@6.0.3))(@vinejs/vine@4.4.0)(pino-pretty@13.1.3)(youch@4.1.1))(@adonisjs/session@8.1.0(@adonisjs/assembler@8.4.0(typescript@6.0.3))(@adonisjs/core@7.3.2(@adonisjs/assembler@8.4.0(typescript@6.0.3))(@vinejs/vine@4.4.0)(pino-pretty@13.1.3)(youch@4.1.1))(@adonisjs/lucid@22.4.2(@adonisjs/assembler@8.4.0(typescript@6.0.3))(@adonisjs/core@7.3.2(@adonisjs/assembler@8.4.0(typescript@6.0.3))(@vinejs/vine@4.4.0)(pino-pretty@13.1.3)(youch@4.1.1))(@vinejs/vine@4.4.0)(better-sqlite3@12.9.0)(luxon@3.7.2)(pg@8.20.0))(@japa/api-client@3.2.1(@japa/assert@4.2.0(@japa/runner@5.3.0))(@japa/runner@5.3.0))(@japa/plugin-adonisjs@5.2.0(@adonisjs/core@7.3.2(@adonisjs/assembler@8.4.0(typescript@6.0.3))(@vinejs/vine@4.4.0)(pino-pretty@13.1.3)(youch@4.1.1))(@japa/api-client@3.2.1(@japa/assert@4.2.0(@japa/runner@5.3.0))(@japa/runner@5.3.0))(@japa/runner@5.3.0))) '@adonisjs/auth': specifier: ^10.1.0 version: 10.1.0(cb463dcab987fc365459355e33b96486) @@ -314,6 +317,25 @@ packages: peerDependencies: youch: ^4.1.0-beta.11 || ^4.1.0 + '@adonisjs/ally@6.3.0': + resolution: {integrity: sha512-yxw9mpQHexRwKx3qpR651yn/a+gYYHne0Cc6fjMxVQ8rOaBZS2e1025wmuhHg7gProLCv8lpSbqF7I9Hk7qWAQ==} + engines: {node: '>=20.6.0'} + peerDependencies: + '@adonisjs/assembler': ^8.0.0 + '@adonisjs/core': ^7.0.0-next.8 || ^7.0.0 + '@adonisjs/i18n': ^3.0.0 + '@adonisjs/inertia': ^4.2.0 + '@adonisjs/session': ^8.0.0 + peerDependenciesMeta: + '@adonisjs/assembler': + optional: true + '@adonisjs/i18n': + optional: true + '@adonisjs/inertia': + optional: true + '@adonisjs/session': + optional: true + '@adonisjs/application@9.0.0': resolution: {integrity: sha512-iQpq/JRJsnrqOMHfu72CYjmlkH5FwT28DhUKEOjktccmFh8OLdVZ2Sieb8b2/qNv4c+w8Yo7keOGEzOYUrU+kA==} engines: {node: '>=24.0.0'} @@ -1539,6 +1561,10 @@ packages: '@poppinss/multiparty@3.0.0': resolution: {integrity: sha512-z9jchUzsv7E+7sa4tWHb0+95Byx7w0ydlPGxg3nzyb7h3QlRdeW8/QkU9SexUY4lsT12do93AfNBAhSuOoVqjA==} + '@poppinss/oauth-client@7.2.0': + resolution: {integrity: sha512-LUCd/fIm2oOeTltnZRSJIxZL9uy9+qHpgtNtxIO+wp8E/1Dd7ogbqIWUFYMufCxTekOoYyQlWOcOQ9rmUw5D5g==} + engines: {node: '>=24.0.0'} + '@poppinss/object-builder@1.1.0': resolution: {integrity: sha512-FOrOq52l7u8goR5yncX14+k+Ewi5djnrt1JwXeS/FvnwAPOiveFhiczCDuvXdssAwamtrV2hp5Rw9v+n2T7hQg==} engines: {node: '>=20.6.0'} @@ -5754,6 +5780,14 @@ snapshots: yargs-parser: 22.0.0 youch: 4.1.1 + '@adonisjs/ally@6.3.0(@adonisjs/assembler@8.4.0(typescript@6.0.3))(@adonisjs/core@7.3.2(@adonisjs/assembler@8.4.0(typescript@6.0.3))(@vinejs/vine@4.4.0)(pino-pretty@13.1.3)(youch@4.1.1))(@adonisjs/session@8.1.0(@adonisjs/assembler@8.4.0(typescript@6.0.3))(@adonisjs/core@7.3.2(@adonisjs/assembler@8.4.0(typescript@6.0.3))(@vinejs/vine@4.4.0)(pino-pretty@13.1.3)(youch@4.1.1))(@adonisjs/lucid@22.4.2(@adonisjs/assembler@8.4.0(typescript@6.0.3))(@adonisjs/core@7.3.2(@adonisjs/assembler@8.4.0(typescript@6.0.3))(@vinejs/vine@4.4.0)(pino-pretty@13.1.3)(youch@4.1.1))(@vinejs/vine@4.4.0)(better-sqlite3@12.9.0)(luxon@3.7.2)(pg@8.20.0))(@japa/api-client@3.2.1(@japa/assert@4.2.0(@japa/runner@5.3.0))(@japa/runner@5.3.0))(@japa/plugin-adonisjs@5.2.0(@adonisjs/core@7.3.2(@adonisjs/assembler@8.4.0(typescript@6.0.3))(@vinejs/vine@4.4.0)(pino-pretty@13.1.3)(youch@4.1.1))(@japa/api-client@3.2.1(@japa/assert@4.2.0(@japa/runner@5.3.0))(@japa/runner@5.3.0))(@japa/runner@5.3.0)))': + dependencies: + '@adonisjs/core': 7.3.2(@adonisjs/assembler@8.4.0(typescript@6.0.3))(@vinejs/vine@4.4.0)(pino-pretty@13.1.3)(youch@4.1.1) + '@poppinss/oauth-client': 7.2.0 + optionalDependencies: + '@adonisjs/assembler': 8.4.0(typescript@6.0.3) + '@adonisjs/session': 8.1.0(@adonisjs/assembler@8.4.0(typescript@6.0.3))(@adonisjs/core@7.3.2(@adonisjs/assembler@8.4.0(typescript@6.0.3))(@vinejs/vine@4.4.0)(pino-pretty@13.1.3)(youch@4.1.1))(@adonisjs/lucid@22.4.2(@adonisjs/assembler@8.4.0(typescript@6.0.3))(@adonisjs/core@7.3.2(@adonisjs/assembler@8.4.0(typescript@6.0.3))(@vinejs/vine@4.4.0)(pino-pretty@13.1.3)(youch@4.1.1))(@vinejs/vine@4.4.0)(better-sqlite3@12.9.0)(luxon@3.7.2)(pg@8.20.0))(@japa/api-client@3.2.1(@japa/assert@4.2.0(@japa/runner@5.3.0))(@japa/runner@5.3.0))(@japa/plugin-adonisjs@5.2.0(@adonisjs/core@7.3.2(@adonisjs/assembler@8.4.0(typescript@6.0.3))(@vinejs/vine@4.4.0)(pino-pretty@13.1.3)(youch@4.1.1))(@japa/api-client@3.2.1(@japa/assert@4.2.0(@japa/runner@5.3.0))(@japa/runner@5.3.0))(@japa/runner@5.3.0)) + '@adonisjs/application@9.0.0(@adonisjs/assembler@8.4.0(typescript@6.0.3))(@adonisjs/config@6.1.0)(@adonisjs/fold@11.0.0)': dependencies: '@adonisjs/config': 6.1.0 @@ -7204,6 +7238,11 @@ snapshots: dependencies: http-errors: 2.0.1 + '@poppinss/oauth-client@7.2.0': + dependencies: + '@poppinss/exception': 1.2.3 + ky: 1.14.3 + '@poppinss/object-builder@1.1.0': {} '@poppinss/prompts@3.1.6':