test_mfa_api.py 206 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390339133923393339433953396339733983399340034013402340334043405340634073408340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444344534463447344834493450345134523453345434553456345734583459346034613462346334643465346634673468346934703471347234733474347534763477347834793480348134823483348434853486348734883489349034913492349334943495349634973498349935003501350235033504350535063507350835093510351135123513351435153516351735183519352035213522352335243525352635273528352935303531353235333534353535363537353835393540354135423543354435453546354735483549355035513552355335543555355635573558355935603561356235633564356535663567356835693570357135723573357435753576357735783579358035813582358335843585358635873588358935903591359235933594359535963597359835993600360136023603360436053606360736083609361036113612361336143615361636173618361936203621362236233624362536263627362836293630363136323633363436353636363736383639364036413642364336443645364636473648364936503651365236533654365536563657365836593660366136623663366436653666366736683669367036713672367336743675367636773678367936803681368236833684368536863687368836893690369136923693369436953696369736983699370037013702370337043705370637073708370937103711371237133714371537163717371837193720372137223723372437253726372737283729373037313732373337343735373637373738373937403741374237433744374537463747374837493750375137523753375437553756375737583759376037613762376337643765376637673768376937703771377237733774377537763777377837793780378137823783378437853786378737883789379037913792379337943795379637973798379938003801380238033804380538063807380838093810381138123813381438153816381738183819382038213822382338243825382638273828382938303831383238333834383538363837383838393840384138423843384438453846384738483849385038513852385338543855385638573858385938603861386238633864386538663867386838693870387138723873387438753876387738783879388038813882388338843885388638873888388938903891389238933894389538963897389838993900390139023903390439053906390739083909391039113912391339143915391639173918391939203921392239233924392539263927392839293930393139323933393439353936393739383939394039413942394339443945394639473948394939503951395239533954395539563957395839593960396139623963396439653966396739683969397039713972397339743975397639773978397939803981398239833984398539863987398839893990399139923993399439953996399739983999400040014002400340044005400640074008400940104011401240134014401540164017401840194020402140224023402440254026402740284029403040314032403340344035403640374038403940404041404240434044404540464047404840494050405140524053405440554056405740584059406040614062406340644065406640674068406940704071407240734074407540764077407840794080408140824083408440854086408740884089409040914092409340944095409640974098409941004101410241034104410541064107410841094110411141124113411441154116411741184119412041214122412341244125412641274128412941304131413241334134413541364137413841394140414141424143414441454146414741484149415041514152415341544155415641574158415941604161416241634164416541664167416841694170417141724173417441754176417741784179418041814182418341844185418641874188418941904191419241934194419541964197419841994200420142024203420442054206420742084209421042114212421342144215421642174218421942204221422242234224422542264227422842294230423142324233423442354236423742384239424042414242424342444245424642474248424942504251425242534254425542564257425842594260426142624263426442654266426742684269427042714272427342744275427642774278427942804281428242834284428542864287428842894290429142924293429442954296429742984299430043014302430343044305430643074308430943104311431243134314431543164317431843194320432143224323432443254326432743284329433043314332433343344335433643374338433943404341434243434344434543464347434843494350435143524353435443554356435743584359436043614362436343644365436643674368436943704371437243734374437543764377437843794380438143824383438443854386438743884389439043914392439343944395439643974398439944004401440244034404440544064407440844094410441144124413441444154416441744184419442044214422442344244425442644274428442944304431443244334434443544364437443844394440444144424443444444454446444744484449445044514452445344544455445644574458445944604461446244634464446544664467446844694470447144724473447444754476447744784479448044814482448344844485448644874488448944904491449244934494449544964497449844994500450145024503450445054506450745084509451045114512451345144515451645174518451945204521452245234524452545264527452845294530453145324533453445354536453745384539454045414542454345444545454645474548454945504551455245534554455545564557455845594560456145624563456445654566456745684569457045714572457345744575457645774578457945804581458245834584458545864587458845894590459145924593459445954596459745984599460046014602460346044605460646074608460946104611461246134614461546164617461846194620462146224623462446254626462746284629463046314632463346344635463646374638463946404641464246434644464546464647464846494650465146524653465446554656465746584659466046614662466346644665466646674668466946704671467246734674467546764677467846794680468146824683468446854686468746884689469046914692469346944695469646974698469947004701470247034704470547064707470847094710471147124713471447154716471747184719472047214722472347244725472647274728472947304731473247334734473547364737473847394740474147424743474447454746474747484749475047514752475347544755475647574758475947604761476247634764476547664767476847694770477147724773477447754776477747784779478047814782478347844785478647874788478947904791479247934794479547964797479847994800480148024803480448054806480748084809481048114812481348144815481648174818481948204821482248234824482548264827482848294830483148324833483448354836483748384839484048414842484348444845484648474848484948504851485248534854485548564857485848594860486148624863486448654866486748684869487048714872487348744875487648774878487948804881488248834884488548864887488848894890489148924893489448954896489748984899490049014902490349044905490649074908490949104911491249134914491549164917491849194920492149224923492449254926492749284929493049314932493349344935493649374938493949404941494249434944494549464947494849494950495149524953495449554956495749584959496049614962496349644965496649674968496949704971497249734974497549764977497849794980498149824983498449854986498749884989499049914992499349944995499649974998499950005001500250035004500550065007500850095010501150125013501450155016501750185019502050215022502350245025502650275028502950305031503250335034503550365037503850395040504150425043504450455046504750485049505050515052505350545055505650575058505950605061506250635064506550665067506850695070507150725073507450755076507750785079508050815082508350845085508650875088508950905091509250935094509550965097509850995100510151025103510451055106510751085109511051115112511351145115511651175118511951205121512251235124512551265127512851295130513151325133513451355136513751385139514051415142514351445145514651475148514951505151515251535154515551565157515851595160516151625163516451655166516751685169517051715172517351745175517651775178517951805181518251835184518551865187518851895190519151925193519451955196519751985199520052015202520352045205520652075208520952105211521252135214521552165217521852195220522152225223522452255226522752285229523052315232523352345235523652375238523952405241524252435244524552465247524852495250
  1. """Integration tests for 2FA and OIDC API endpoints.
  2. Tests the full request/response cycle for:
  3. - GET /api/v1/auth/2fa/status
  4. - POST /api/v1/auth/2fa/totp/setup
  5. - POST /api/v1/auth/2fa/totp/enable
  6. - POST /api/v1/auth/2fa/totp/disable
  7. - POST /api/v1/auth/2fa/email/enable
  8. - POST /api/v1/auth/2fa/email/disable
  9. - POST /api/v1/auth/2fa/verify (TOTP, email, backup paths)
  10. - DELETE /api/v1/auth/2fa/admin/{user_id}
  11. - GET /api/v1/auth/oidc/providers
  12. - POST /api/v1/auth/oidc/providers
  13. - PATCH /api/v1/auth/oidc/providers/{id}
  14. - DELETE /api/v1/auth/oidc/providers/{id}
  15. """
  16. from __future__ import annotations
  17. import secrets
  18. import time
  19. from datetime import datetime, timedelta, timezone
  20. from unittest.mock import patch
  21. import jwt as pyjwt
  22. import pyotp
  23. import pytest
  24. from httpx import AsyncClient
  25. from passlib.context import CryptContext
  26. from sqlalchemy.ext.asyncio import AsyncSession
  27. from backend.app.models.auth_ephemeral import AuthEphemeralToken
  28. from backend.app.models.user import User
  29. _pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
  30. # ---------------------------------------------------------------------------
  31. # Fixtures / helpers
  32. # ---------------------------------------------------------------------------
  33. AUTH_SETUP_URL = "/api/v1/auth/setup"
  34. LOGIN_URL = "/api/v1/auth/login"
  35. def _norm_pw(password: str) -> str:
  36. """Ensure password meets complexity requirements (I4: SetupRequest now validates)."""
  37. if not any(c.isupper() for c in password):
  38. password = password[0].upper() + password[1:]
  39. if not any(c not in "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" for c in password):
  40. password = password + "!"
  41. return password
  42. async def _setup_and_login(client: AsyncClient, username: str, password: str) -> str:
  43. """Enable auth, create an admin user, login, and return the bearer token."""
  44. password = _norm_pw(password)
  45. await client.post(
  46. AUTH_SETUP_URL,
  47. json={
  48. "auth_enabled": True,
  49. "admin_username": username,
  50. "admin_password": password,
  51. },
  52. )
  53. resp = await client.post(LOGIN_URL, json={"username": username, "password": password})
  54. assert resp.status_code == 200
  55. return resp.json()["access_token"]
  56. async def _login_get_pre_auth_token(client: AsyncClient, username: str, password: str) -> str:
  57. """Login a user who has 2FA enabled; return the pre_auth_token from the response."""
  58. password = _norm_pw(password)
  59. resp = await client.post(LOGIN_URL, json={"username": username, "password": password})
  60. assert resp.status_code == 200
  61. data = resp.json()
  62. assert data["requires_2fa"] is True, f"Expected requires_2fa=True, got {data}"
  63. assert data["pre_auth_token"] is not None
  64. return data["pre_auth_token"]
  65. def _auth_header(token: str) -> dict[str, str]:
  66. return {"Authorization": f"Bearer {token}"}
  67. # ===========================================================================
  68. # 2FA Status
  69. # ===========================================================================
  70. class TestTwoFAStatus:
  71. """Tests for GET /api/v1/auth/2fa/status."""
  72. @pytest.mark.asyncio
  73. @pytest.mark.integration
  74. async def test_status_requires_auth(self, async_client: AsyncClient):
  75. response = await async_client.get("/api/v1/auth/2fa/status")
  76. assert response.status_code == 401
  77. @pytest.mark.asyncio
  78. @pytest.mark.integration
  79. async def test_status_default_disabled(self, async_client: AsyncClient):
  80. token = await _setup_and_login(async_client, "statususer", "statuspass123")
  81. response = await async_client.get("/api/v1/auth/2fa/status", headers=_auth_header(token))
  82. assert response.status_code == 200
  83. data = response.json()
  84. assert data["totp_enabled"] is False
  85. assert data["email_otp_enabled"] is False
  86. assert data["backup_codes_remaining"] == 0
  87. # ===========================================================================
  88. # TOTP Setup
  89. # ===========================================================================
  90. class TestTOTPSetup:
  91. """Tests for POST /api/v1/auth/2fa/totp/setup."""
  92. @pytest.mark.asyncio
  93. @pytest.mark.integration
  94. async def test_setup_requires_auth(self, async_client: AsyncClient):
  95. response = await async_client.post("/api/v1/auth/2fa/totp/setup")
  96. assert response.status_code == 401
  97. @pytest.mark.asyncio
  98. @pytest.mark.integration
  99. async def test_setup_returns_secret_and_qr(self, async_client: AsyncClient):
  100. token = await _setup_and_login(async_client, "totpsetup", "totpsetup123")
  101. response = await async_client.post("/api/v1/auth/2fa/totp/setup", headers=_auth_header(token))
  102. assert response.status_code == 200
  103. data = response.json()
  104. assert "secret" in data
  105. assert len(data["secret"]) > 0
  106. assert "qr_code_b64" in data
  107. assert data["issuer"] == "Bambuddy"
  108. @pytest.mark.asyncio
  109. @pytest.mark.integration
  110. async def test_setup_secret_is_valid_base32(self, async_client: AsyncClient):
  111. token = await _setup_and_login(async_client, "totpbase32", "totpbase32pw")
  112. response = await async_client.post("/api/v1/auth/2fa/totp/setup", headers=_auth_header(token))
  113. assert response.status_code == 200
  114. secret = response.json()["secret"]
  115. # pyotp will raise on invalid base32
  116. totp = pyotp.TOTP(secret)
  117. assert len(totp.now()) == 6
  118. # ===========================================================================
  119. # TOTP Enable
  120. # ===========================================================================
  121. class TestTOTPEnable:
  122. """Tests for POST /api/v1/auth/2fa/totp/enable."""
  123. @pytest.mark.asyncio
  124. @pytest.mark.integration
  125. async def test_enable_without_setup_returns_400(self, async_client: AsyncClient):
  126. token = await _setup_and_login(async_client, "nosetupenable", "nosetupenable1")
  127. response = await async_client.post(
  128. "/api/v1/auth/2fa/totp/enable",
  129. json={"code": "123456"},
  130. headers=_auth_header(token),
  131. )
  132. assert response.status_code == 400
  133. @pytest.mark.asyncio
  134. @pytest.mark.integration
  135. async def test_enable_with_invalid_code_returns_400(self, async_client: AsyncClient):
  136. token = await _setup_and_login(async_client, "badcodeuser", "badcodeuser1")
  137. await async_client.post("/api/v1/auth/2fa/totp/setup", headers=_auth_header(token))
  138. response = await async_client.post(
  139. "/api/v1/auth/2fa/totp/enable",
  140. json={"code": "000000"},
  141. headers=_auth_header(token),
  142. )
  143. assert response.status_code == 400
  144. @pytest.mark.asyncio
  145. @pytest.mark.integration
  146. async def test_enable_with_valid_code_returns_backup_codes(self, async_client: AsyncClient):
  147. token = await _setup_and_login(async_client, "enableok", "enableok123")
  148. setup_resp = await async_client.post("/api/v1/auth/2fa/totp/setup", headers=_auth_header(token))
  149. secret = setup_resp.json()["secret"]
  150. valid_code = pyotp.TOTP(secret).now()
  151. response = await async_client.post(
  152. "/api/v1/auth/2fa/totp/enable",
  153. json={"code": valid_code},
  154. headers=_auth_header(token),
  155. )
  156. assert response.status_code == 200
  157. data = response.json()
  158. assert "backup_codes" in data
  159. assert len(data["backup_codes"]) == 10
  160. for code in data["backup_codes"]:
  161. assert len(code) == 8
  162. @pytest.mark.asyncio
  163. @pytest.mark.integration
  164. async def test_status_reflects_enabled_totp(self, async_client: AsyncClient):
  165. token = await _setup_and_login(async_client, "statustotp", "statustotp1")
  166. setup_resp = await async_client.post("/api/v1/auth/2fa/totp/setup", headers=_auth_header(token))
  167. secret = setup_resp.json()["secret"]
  168. valid_code = pyotp.TOTP(secret).now()
  169. await async_client.post(
  170. "/api/v1/auth/2fa/totp/enable",
  171. json={"code": valid_code},
  172. headers=_auth_header(token),
  173. )
  174. status_resp = await async_client.get("/api/v1/auth/2fa/status", headers=_auth_header(token))
  175. data = status_resp.json()
  176. assert data["totp_enabled"] is True
  177. assert data["backup_codes_remaining"] == 10
  178. # ===========================================================================
  179. # TOTP Disable
  180. # ===========================================================================
  181. class TestTOTPDisable:
  182. """Tests for POST /api/v1/auth/2fa/totp/disable."""
  183. @pytest.mark.asyncio
  184. @pytest.mark.integration
  185. async def test_disable_when_not_enabled_returns_400(self, async_client: AsyncClient):
  186. token = await _setup_and_login(async_client, "disablenoenab", "disablenoenab1")
  187. response = await async_client.post(
  188. "/api/v1/auth/2fa/totp/disable",
  189. json={"code": "123456"},
  190. headers=_auth_header(token),
  191. )
  192. assert response.status_code == 400
  193. @pytest.mark.asyncio
  194. @pytest.mark.integration
  195. async def test_disable_with_valid_code(self, async_client: AsyncClient):
  196. token = await _setup_and_login(async_client, "disableok", "disableok123")
  197. setup_resp = await async_client.post("/api/v1/auth/2fa/totp/setup", headers=_auth_header(token))
  198. secret = setup_resp.json()["secret"]
  199. valid_code = pyotp.TOTP(secret).now()
  200. await async_client.post(
  201. "/api/v1/auth/2fa/totp/enable",
  202. json={"code": valid_code},
  203. headers=_auth_header(token),
  204. )
  205. # Disable with a fresh valid code
  206. disable_code = pyotp.TOTP(secret).now()
  207. response = await async_client.post(
  208. "/api/v1/auth/2fa/totp/disable",
  209. json={"code": disable_code},
  210. headers=_auth_header(token),
  211. )
  212. assert response.status_code == 200
  213. assert "disabled" in response.json()["message"].lower()
  214. # Status should now show disabled
  215. status_resp = await async_client.get("/api/v1/auth/2fa/status", headers=_auth_header(token))
  216. assert status_resp.json()["totp_enabled"] is False
  217. # ===========================================================================
  218. # Email OTP Enable/Disable
  219. # ===========================================================================
  220. class TestEmailOTP:
  221. """Tests for POST /api/v1/auth/2fa/email/enable, /enable/confirm and /disable."""
  222. @pytest.mark.asyncio
  223. @pytest.mark.integration
  224. async def test_enable_email_otp_without_email_returns_400(self, async_client: AsyncClient):
  225. """Users without an email address cannot enable email OTP."""
  226. token = await _setup_and_login(async_client, "noemailuser", "noemailuser1")
  227. response = await async_client.post("/api/v1/auth/2fa/email/enable", headers=_auth_header(token))
  228. assert response.status_code == 400
  229. assert "email" in response.json()["detail"].lower()
  230. @pytest.mark.asyncio
  231. @pytest.mark.integration
  232. async def test_confirm_enable_email_otp_happy_path(self, async_client: AsyncClient, db_session: AsyncSession):
  233. """Confirm step activates email OTP when setup_token + code are valid (C5)."""
  234. token = await _setup_and_login(async_client, "confirmenable", "confirmenable1")
  235. # Give user an email address directly (SMTP not available in tests)
  236. from sqlalchemy import select as sa_select
  237. result = await db_session.execute(sa_select(User).where(User.username == "confirmenable"))
  238. user = result.scalar_one()
  239. user.email = "confirmenable@example.com"
  240. await db_session.commit()
  241. # Inject a known setup token directly into the DB (bypasses SMTP)
  242. code = "123456"
  243. code_hash = _pwd_context.hash(code)
  244. setup_token = secrets.token_urlsafe(32)
  245. db_session.add(
  246. AuthEphemeralToken(
  247. token=setup_token,
  248. token_type="email_otp_setup",
  249. username="confirmenable",
  250. nonce=code_hash,
  251. expires_at=datetime.now(timezone.utc) + timedelta(minutes=10),
  252. )
  253. )
  254. await db_session.commit()
  255. resp = await async_client.post(
  256. "/api/v1/auth/2fa/email/enable/confirm",
  257. json={"setup_token": setup_token, "code": code},
  258. headers=_auth_header(token),
  259. )
  260. assert resp.status_code == 200
  261. status_resp = await async_client.get("/api/v1/auth/2fa/status", headers=_auth_header(token))
  262. assert status_resp.json()["email_otp_enabled"] is True
  263. @pytest.mark.asyncio
  264. @pytest.mark.integration
  265. async def test_confirm_enable_email_otp_wrong_code(self, async_client: AsyncClient, db_session: AsyncSession):
  266. """Wrong code on confirm step returns 400 and does not enable email OTP."""
  267. token = await _setup_and_login(async_client, "confirmwrong", "confirmwrong1")
  268. code_hash = _pwd_context.hash("654321")
  269. setup_token = secrets.token_urlsafe(32)
  270. db_session.add(
  271. AuthEphemeralToken(
  272. token=setup_token,
  273. token_type="email_otp_setup",
  274. username="confirmwrong",
  275. nonce=code_hash,
  276. expires_at=datetime.now(timezone.utc) + timedelta(minutes=10),
  277. )
  278. )
  279. await db_session.commit()
  280. resp = await async_client.post(
  281. "/api/v1/auth/2fa/email/enable/confirm",
  282. json={"setup_token": setup_token, "code": "000000"},
  283. headers=_auth_header(token),
  284. )
  285. assert resp.status_code == 400
  286. @pytest.mark.asyncio
  287. @pytest.mark.integration
  288. async def test_confirm_enable_email_otp_setup_token_is_single_use(
  289. self, async_client: AsyncClient, db_session: AsyncSession
  290. ):
  291. """Setup token is consumed on first use; replay returns 400."""
  292. token = await _setup_and_login(async_client, "confirmonce", "confirmonce1")
  293. code = "111111"
  294. code_hash = _pwd_context.hash(code)
  295. setup_token = secrets.token_urlsafe(32)
  296. db_session.add(
  297. AuthEphemeralToken(
  298. token=setup_token,
  299. token_type="email_otp_setup",
  300. username="confirmonce",
  301. nonce=code_hash,
  302. expires_at=datetime.now(timezone.utc) + timedelta(minutes=10),
  303. )
  304. )
  305. await db_session.commit()
  306. first = await async_client.post(
  307. "/api/v1/auth/2fa/email/enable/confirm",
  308. json={"setup_token": setup_token, "code": code},
  309. headers=_auth_header(token),
  310. )
  311. assert first.status_code == 200
  312. second = await async_client.post(
  313. "/api/v1/auth/2fa/email/enable/confirm",
  314. json={"setup_token": setup_token, "code": code},
  315. headers=_auth_header(token),
  316. )
  317. assert second.status_code == 400
  318. @pytest.mark.asyncio
  319. @pytest.mark.integration
  320. async def test_disable_email_otp_requires_password(self, async_client: AsyncClient):
  321. """Disabling email OTP requires the account password (C6: re-auth)."""
  322. token = await _setup_and_login(async_client, "disemailotp", "disemailotp1")
  323. # Wrong password → 401
  324. response = await async_client.post(
  325. "/api/v1/auth/2fa/email/disable",
  326. json={"password": "wrongpassword"},
  327. headers=_auth_header(token),
  328. )
  329. assert response.status_code == 401
  330. @pytest.mark.asyncio
  331. @pytest.mark.integration
  332. async def test_disable_email_otp_when_enabled(self, async_client: AsyncClient, db_session: AsyncSession):
  333. """Disabling email OTP when enabled turns it off and status reflects that."""
  334. token = await _setup_and_login(async_client, "disemailpw", "disemailpw1")
  335. # Enable email OTP via direct DB injection (no SMTP)
  336. code = "222222"
  337. setup_token = secrets.token_urlsafe(32)
  338. db_session.add(
  339. AuthEphemeralToken(
  340. token=setup_token,
  341. token_type="email_otp_setup",
  342. username="disemailpw",
  343. nonce=_pwd_context.hash(code),
  344. expires_at=datetime.now(timezone.utc) + timedelta(minutes=10),
  345. )
  346. )
  347. await db_session.commit()
  348. await async_client.post(
  349. "/api/v1/auth/2fa/email/enable/confirm",
  350. json={"setup_token": setup_token, "code": code},
  351. headers=_auth_header(token),
  352. )
  353. # Now disable
  354. response = await async_client.post(
  355. "/api/v1/auth/2fa/email/disable",
  356. json={"password": _norm_pw("disemailpw1")},
  357. headers=_auth_header(token),
  358. )
  359. assert response.status_code == 200
  360. status_resp = await async_client.get("/api/v1/auth/2fa/status", headers=_auth_header(token))
  361. assert status_resp.json()["email_otp_enabled"] is False
  362. # ===========================================================================
  363. # 2FA Verify — TOTP path
  364. # ===========================================================================
  365. class TestTwoFAVerifyTOTP:
  366. """Tests for POST /api/v1/auth/2fa/verify using the TOTP method."""
  367. @pytest.mark.asyncio
  368. @pytest.mark.integration
  369. async def test_verify_with_invalid_pre_auth_token(self, async_client: AsyncClient):
  370. response = await async_client.post(
  371. "/api/v1/auth/2fa/verify",
  372. json={"pre_auth_token": "bogus", "method": "totp", "code": "123456"},
  373. )
  374. assert response.status_code == 401
  375. @pytest.mark.asyncio
  376. @pytest.mark.integration
  377. async def test_verify_totp_issues_jwt(self, async_client: AsyncClient):
  378. """Full flow: setup → enable TOTP → login → pre_auth_token → verify → JWT."""
  379. token = await _setup_and_login(async_client, "verifytotpok", "verifytotpok1")
  380. setup_resp = await async_client.post("/api/v1/auth/2fa/totp/setup", headers=_auth_header(token))
  381. secret = setup_resp.json()["secret"]
  382. valid_code = pyotp.TOTP(secret).now()
  383. await async_client.post(
  384. "/api/v1/auth/2fa/totp/enable",
  385. json={"code": valid_code},
  386. headers=_auth_header(token),
  387. )
  388. # Login now returns requires_2fa=True + pre_auth_token
  389. pre_auth_token = await _login_get_pre_auth_token(async_client, "verifytotpok", "verifytotpok1")
  390. verify_resp = await async_client.post(
  391. "/api/v1/auth/2fa/verify",
  392. json={
  393. "pre_auth_token": pre_auth_token,
  394. "method": "totp",
  395. "code": pyotp.TOTP(secret).now(),
  396. },
  397. )
  398. assert verify_resp.status_code == 200
  399. data = verify_resp.json()
  400. assert "access_token" in data
  401. assert data["token_type"] == "bearer"
  402. assert data["user"]["username"] == "verifytotpok"
  403. @pytest.mark.asyncio
  404. @pytest.mark.integration
  405. async def test_verify_totp_invalid_code(self, async_client: AsyncClient):
  406. token = await _setup_and_login(async_client, "verifybadcode", "verifybadcode1")
  407. setup_resp = await async_client.post("/api/v1/auth/2fa/totp/setup", headers=_auth_header(token))
  408. secret = setup_resp.json()["secret"]
  409. valid_code = pyotp.TOTP(secret).now()
  410. await async_client.post(
  411. "/api/v1/auth/2fa/totp/enable",
  412. json={"code": valid_code},
  413. headers=_auth_header(token),
  414. )
  415. pre_auth_token = await _login_get_pre_auth_token(async_client, "verifybadcode", "verifybadcode1")
  416. verify_resp = await async_client.post(
  417. "/api/v1/auth/2fa/verify",
  418. json={"pre_auth_token": pre_auth_token, "method": "totp", "code": "000000"},
  419. )
  420. assert verify_resp.status_code == 401
  421. @pytest.mark.asyncio
  422. @pytest.mark.integration
  423. async def test_verify_invalid_method(self, async_client: AsyncClient):
  424. """An invalid 2FA method should return 400 even with a valid pre_auth_token."""
  425. token = await _setup_and_login(async_client, "invalidmethod", "invalidmethod1")
  426. setup_resp = await async_client.post("/api/v1/auth/2fa/totp/setup", headers=_auth_header(token))
  427. secret = setup_resp.json()["secret"]
  428. valid_code = pyotp.TOTP(secret).now()
  429. await async_client.post(
  430. "/api/v1/auth/2fa/totp/enable",
  431. json={"code": valid_code},
  432. headers=_auth_header(token),
  433. )
  434. pre_auth_token = await _login_get_pre_auth_token(async_client, "invalidmethod", "invalidmethod1")
  435. response = await async_client.post(
  436. "/api/v1/auth/2fa/verify",
  437. json={"pre_auth_token": pre_auth_token, "method": "sms", "code": "123456"},
  438. )
  439. assert response.status_code == 422 # Pydantic Literal validation
  440. # ===========================================================================
  441. # 2FA Verify — Backup code path
  442. # ===========================================================================
  443. class TestTwoFAVerifyBackup:
  444. """Tests for POST /api/v1/auth/2fa/verify using the backup method."""
  445. @pytest.mark.asyncio
  446. @pytest.mark.integration
  447. async def test_verify_with_backup_code(self, async_client: AsyncClient):
  448. token = await _setup_and_login(async_client, "backupcodeok", "backupcodeok1")
  449. setup_resp = await async_client.post("/api/v1/auth/2fa/totp/setup", headers=_auth_header(token))
  450. secret = setup_resp.json()["secret"]
  451. valid_code = pyotp.TOTP(secret).now()
  452. enable_resp = await async_client.post(
  453. "/api/v1/auth/2fa/totp/enable",
  454. json={"code": valid_code},
  455. headers=_auth_header(token),
  456. )
  457. backup_code = enable_resp.json()["backup_codes"][0]
  458. pre_auth_token = await _login_get_pre_auth_token(async_client, "backupcodeok", "backupcodeok1")
  459. verify_resp = await async_client.post(
  460. "/api/v1/auth/2fa/verify",
  461. json={"pre_auth_token": pre_auth_token, "method": "backup", "code": backup_code},
  462. )
  463. assert verify_resp.status_code == 200
  464. assert "access_token" in verify_resp.json()
  465. @pytest.mark.asyncio
  466. @pytest.mark.integration
  467. async def test_backup_code_is_single_use(self, async_client: AsyncClient):
  468. token = await _setup_and_login(async_client, "backupsingle", "backupsingle1")
  469. setup_resp = await async_client.post("/api/v1/auth/2fa/totp/setup", headers=_auth_header(token))
  470. secret = setup_resp.json()["secret"]
  471. valid_code = pyotp.TOTP(secret).now()
  472. enable_resp = await async_client.post(
  473. "/api/v1/auth/2fa/totp/enable",
  474. json={"code": valid_code},
  475. headers=_auth_header(token),
  476. )
  477. backup_code = enable_resp.json()["backup_codes"][0]
  478. # First use — should succeed
  479. pre_auth_token = await _login_get_pre_auth_token(async_client, "backupsingle", "backupsingle1")
  480. first_resp = await async_client.post(
  481. "/api/v1/auth/2fa/verify",
  482. json={"pre_auth_token": pre_auth_token, "method": "backup", "code": backup_code},
  483. )
  484. assert first_resp.status_code == 200
  485. # Second use of the same code — must fail (need new pre_auth_token + same backup code)
  486. pre_auth_token2 = await _login_get_pre_auth_token(async_client, "backupsingle", "backupsingle1")
  487. second_resp = await async_client.post(
  488. "/api/v1/auth/2fa/verify",
  489. json={"pre_auth_token": pre_auth_token2, "method": "backup", "code": backup_code},
  490. )
  491. assert second_resp.status_code == 401
  492. @pytest.mark.asyncio
  493. @pytest.mark.integration
  494. async def test_backup_code_count_decrements(self, async_client: AsyncClient):
  495. token = await _setup_and_login(async_client, "backupcount", "backupcount1")
  496. setup_resp = await async_client.post("/api/v1/auth/2fa/totp/setup", headers=_auth_header(token))
  497. secret = setup_resp.json()["secret"]
  498. valid_code = pyotp.TOTP(secret).now()
  499. enable_resp = await async_client.post(
  500. "/api/v1/auth/2fa/totp/enable",
  501. json={"code": valid_code},
  502. headers=_auth_header(token),
  503. )
  504. backup_code = enable_resp.json()["backup_codes"][0]
  505. pre_auth_token = await _login_get_pre_auth_token(async_client, "backupcount", "backupcount1")
  506. await async_client.post(
  507. "/api/v1/auth/2fa/verify",
  508. json={"pre_auth_token": pre_auth_token, "method": "backup", "code": backup_code},
  509. )
  510. # Status is readable with the original full token (still valid)
  511. status_resp = await async_client.get("/api/v1/auth/2fa/status", headers=_auth_header(token))
  512. assert status_resp.json()["backup_codes_remaining"] == 9
  513. # ===========================================================================
  514. # Rate Limiting
  515. # ===========================================================================
  516. class TestRateLimiting:
  517. """Ensure 429 is returned after 5 failed 2FA attempts."""
  518. @pytest.mark.asyncio
  519. @pytest.mark.integration
  520. async def test_rate_limit_lockout(self, async_client: AsyncClient):
  521. """After 5 failed TOTP attempts the 6th must return 429."""
  522. token = await _setup_and_login(async_client, "ratelimituser", "ratelimituser1")
  523. setup_resp = await async_client.post("/api/v1/auth/2fa/totp/setup", headers=_auth_header(token))
  524. secret = setup_resp.json()["secret"]
  525. valid_code = pyotp.TOTP(secret).now()
  526. await async_client.post(
  527. "/api/v1/auth/2fa/totp/enable",
  528. json={"code": valid_code},
  529. headers=_auth_header(token),
  530. )
  531. # 5 failed attempts via the login → pre_auth_token → verify flow
  532. for _ in range(5):
  533. pre_auth_token = await _login_get_pre_auth_token(async_client, "ratelimituser", "ratelimituser1")
  534. await async_client.post(
  535. "/api/v1/auth/2fa/verify",
  536. json={"pre_auth_token": pre_auth_token, "method": "totp", "code": "000000"},
  537. )
  538. # 6th attempt should hit the rate limit
  539. pre_auth_token = await _login_get_pre_auth_token(async_client, "ratelimituser", "ratelimituser1")
  540. response = await async_client.post(
  541. "/api/v1/auth/2fa/verify",
  542. json={"pre_auth_token": pre_auth_token, "method": "totp", "code": "000000"},
  543. )
  544. assert response.status_code == 429
  545. # ===========================================================================
  546. # Admin 2FA Disable
  547. # ===========================================================================
  548. class TestAdminDisable2FA:
  549. """Tests for DELETE /api/v1/auth/2fa/admin/{user_id}."""
  550. @pytest.mark.asyncio
  551. @pytest.mark.integration
  552. async def test_admin_disable_reports_a_user_without_2fa(self, async_client: AsyncClient):
  553. """The Edit user dialog says so, rather than claiming it removed something."""
  554. token = await _setup_and_login(async_client, "adminno2fa", "adminno2fa123")
  555. created = await async_client.post(
  556. "/api/v1/users",
  557. json={"username": "plainuser2fa", "password": "Plainuser2fa1!"},
  558. headers=_auth_header(token),
  559. )
  560. response = await async_client.request(
  561. "DELETE",
  562. f"/api/v1/auth/2fa/admin/{created.json()['id']}",
  563. json={"admin_password": _norm_pw("adminno2fa123")},
  564. headers=_auth_header(token),
  565. )
  566. assert response.status_code == 200
  567. assert response.json()["had_2fa"] is False
  568. @pytest.mark.asyncio
  569. @pytest.mark.integration
  570. async def test_admin_disable_reports_email_2fa(self, async_client: AsyncClient, db_session):
  571. from backend.app.api.routes.settings import set_setting
  572. token = await _setup_and_login(async_client, "adminmail2fa", "adminmail2fa123")
  573. created = await async_client.post(
  574. "/api/v1/users",
  575. json={"username": "mailuser2fa", "password": "Mailuser2fa1!"},
  576. headers=_auth_header(token),
  577. )
  578. user_id = created.json()["id"]
  579. await set_setting(db_session, f"user_{user_id}_email_2fa_enabled", "true")
  580. await db_session.commit()
  581. response = await async_client.request(
  582. "DELETE",
  583. f"/api/v1/auth/2fa/admin/{user_id}",
  584. json={"admin_password": _norm_pw("adminmail2fa123")},
  585. headers=_auth_header(token),
  586. )
  587. assert response.status_code == 200
  588. assert response.json()["had_2fa"] is True
  589. @pytest.mark.asyncio
  590. @pytest.mark.integration
  591. async def test_admin_disable_unknown_user_is_404(self, async_client: AsyncClient):
  592. token = await _setup_and_login(async_client, "admincheck", "admincheck123")
  593. response = await async_client.request(
  594. "DELETE",
  595. "/api/v1/auth/2fa/admin/99999",
  596. json={"admin_password": _norm_pw("admincheck123")},
  597. headers=_auth_header(token),
  598. )
  599. assert response.status_code == 404
  600. @pytest.mark.asyncio
  601. @pytest.mark.integration
  602. async def test_admin_disable_clears_totp(self, async_client: AsyncClient):
  603. from sqlalchemy import select
  604. from backend.app.models.user import User
  605. token = await _setup_and_login(async_client, "admintotp", "admintotp123")
  606. setup_resp = await async_client.post("/api/v1/auth/2fa/totp/setup", headers=_auth_header(token))
  607. secret = setup_resp.json()["secret"]
  608. valid_code = pyotp.TOTP(secret).now()
  609. await async_client.post(
  610. "/api/v1/auth/2fa/totp/enable",
  611. json={"code": valid_code},
  612. headers=_auth_header(token),
  613. )
  614. # Find the user's id by querying status (which works with the token)
  615. me_resp = await async_client.get("/api/v1/auth/me", headers=_auth_header(token))
  616. user_id = me_resp.json()["id"]
  617. response = await async_client.request(
  618. "DELETE",
  619. f"/api/v1/auth/2fa/admin/{user_id}",
  620. json={"admin_password": _norm_pw("admintotp123")},
  621. headers=_auth_header(token),
  622. )
  623. assert response.status_code == 200
  624. assert response.json()["had_2fa"] is True
  625. # I2: admin_disable_2fa bumps password_changed_at, invalidating the old token.
  626. # Re-login to get a fresh token before checking status.
  627. new_login = await async_client.post(
  628. LOGIN_URL, json={"username": "admintotp", "password": _norm_pw("admintotp123")}
  629. )
  630. assert new_login.status_code == 200, f"re-login failed: {new_login.json()}"
  631. assert new_login.json().get("requires_2fa") is False, f"still requires 2FA: {new_login.json()}"
  632. new_token = new_login.json()["access_token"]
  633. assert new_token is not None, f"no access_token in: {new_login.json()}"
  634. # Status should now show TOTP disabled
  635. status_resp = await async_client.get("/api/v1/auth/2fa/status", headers=_auth_header(new_token))
  636. assert status_resp.status_code == 200, f"status check failed: {status_resp.json()}"
  637. assert status_resp.json()["totp_enabled"] is False
  638. # ===========================================================================
  639. # OIDC Provider CRUD
  640. # ===========================================================================
  641. class TestOIDCProviders:
  642. """Tests for OIDC provider management endpoints."""
  643. @pytest.mark.asyncio
  644. @pytest.mark.integration
  645. async def test_list_public_providers_empty(self, async_client: AsyncClient):
  646. response = await async_client.get("/api/v1/auth/oidc/providers")
  647. assert response.status_code == 200
  648. assert isinstance(response.json(), list)
  649. @pytest.mark.asyncio
  650. @pytest.mark.integration
  651. async def test_create_provider_requires_admin(self, async_client: AsyncClient):
  652. token = await _setup_and_login(async_client, "oidcadmincreate", "oidcadmincreate1")
  653. response = await async_client.post(
  654. "/api/v1/auth/oidc/providers",
  655. json={
  656. "name": "PocketID",
  657. "issuer_url": "https://auth.example.com",
  658. "client_id": "bambuddy",
  659. "client_secret": "supersecret",
  660. "scopes": "openid email profile",
  661. "is_enabled": True,
  662. "auto_create_users": False,
  663. },
  664. headers=_auth_header(token),
  665. )
  666. assert response.status_code == 201
  667. data = response.json()
  668. assert data["name"] == "PocketID"
  669. assert data["issuer_url"] == "https://auth.example.com"
  670. assert "client_secret" not in data # Secret must not be returned
  671. @pytest.mark.asyncio
  672. @pytest.mark.integration
  673. async def test_created_provider_appears_in_all_list(self, async_client: AsyncClient):
  674. token = await _setup_and_login(async_client, "oidclistall", "oidclistall123")
  675. await async_client.post(
  676. "/api/v1/auth/oidc/providers",
  677. json={
  678. "name": "TestProvider",
  679. "issuer_url": "https://test.example.com",
  680. "client_id": "testclient",
  681. "client_secret": "testsecret",
  682. "scopes": "openid",
  683. "is_enabled": True,
  684. "auto_create_users": False,
  685. },
  686. headers=_auth_header(token),
  687. )
  688. response = await async_client.get("/api/v1/auth/oidc/providers/all", headers=_auth_header(token))
  689. assert response.status_code == 200
  690. names = [p["name"] for p in response.json()]
  691. assert "TestProvider" in names
  692. @pytest.mark.asyncio
  693. @pytest.mark.integration
  694. async def test_disabled_provider_not_in_public_list(self, async_client: AsyncClient):
  695. token = await _setup_and_login(async_client, "oidcdisabled", "oidcdisabled1")
  696. await async_client.post(
  697. "/api/v1/auth/oidc/providers",
  698. json={
  699. "name": "DisabledProvider",
  700. "issuer_url": "https://disabled.example.com",
  701. "client_id": "dc",
  702. "client_secret": "ds",
  703. "scopes": "openid",
  704. "is_enabled": False,
  705. "auto_create_users": False,
  706. },
  707. headers=_auth_header(token),
  708. )
  709. response = await async_client.get("/api/v1/auth/oidc/providers")
  710. names = [p["name"] for p in response.json()]
  711. assert "DisabledProvider" not in names
  712. @pytest.mark.asyncio
  713. @pytest.mark.integration
  714. async def test_update_provider(self, async_client: AsyncClient):
  715. token = await _setup_and_login(async_client, "oidcupdate", "oidcupdate123")
  716. create_resp = await async_client.post(
  717. "/api/v1/auth/oidc/providers",
  718. json={
  719. "name": "OldName",
  720. "issuer_url": "https://update.example.com",
  721. "client_id": "uc",
  722. "client_secret": "us",
  723. "scopes": "openid",
  724. "is_enabled": True,
  725. "auto_create_users": False,
  726. },
  727. headers=_auth_header(token),
  728. )
  729. provider_id = create_resp.json()["id"]
  730. put_resp = await async_client.put(
  731. f"/api/v1/auth/oidc/providers/{provider_id}",
  732. json={"name": "NewName"},
  733. headers=_auth_header(token),
  734. )
  735. assert put_resp.status_code == 200
  736. assert put_resp.json()["name"] == "NewName"
  737. @pytest.mark.asyncio
  738. @pytest.mark.integration
  739. async def test_delete_provider(self, async_client: AsyncClient):
  740. token = await _setup_and_login(async_client, "oidcdelete", "oidcdelete123")
  741. create_resp = await async_client.post(
  742. "/api/v1/auth/oidc/providers",
  743. json={
  744. "name": "ToDelete",
  745. "issuer_url": "https://delete.example.com",
  746. "client_id": "dc",
  747. "client_secret": "ds",
  748. "scopes": "openid",
  749. "is_enabled": True,
  750. "auto_create_users": False,
  751. },
  752. headers=_auth_header(token),
  753. )
  754. provider_id = create_resp.json()["id"]
  755. del_resp = await async_client.delete(
  756. f"/api/v1/auth/oidc/providers/{provider_id}",
  757. headers=_auth_header(token),
  758. )
  759. assert del_resp.status_code == 200
  760. # No longer in list
  761. all_resp = await async_client.get("/api/v1/auth/oidc/providers/all", headers=_auth_header(token))
  762. ids = [p["id"] for p in all_resp.json()]
  763. assert provider_id not in ids
  764. @pytest.mark.asyncio
  765. @pytest.mark.integration
  766. async def test_update_nonexistent_provider_returns_404(self, async_client: AsyncClient):
  767. token = await _setup_and_login(async_client, "oidc404", "oidc404pass1")
  768. response = await async_client.put(
  769. "/api/v1/auth/oidc/providers/99999",
  770. json={"name": "ghost"},
  771. headers=_auth_header(token),
  772. )
  773. assert response.status_code == 404
  774. @pytest.mark.asyncio
  775. @pytest.mark.integration
  776. async def test_create_provider_with_default_group_id(self, async_client: AsyncClient, db_session: AsyncSession):
  777. """Creating a provider with a valid default_group_id stores and returns the value."""
  778. from sqlalchemy import select
  779. from backend.app.models.group import Group
  780. token = await _setup_and_login(async_client, "oidcdg_create", "OidcDgCreate1!")
  781. grp_result = await db_session.execute(select(Group).where(Group.name == "Operators"))
  782. operators = grp_result.scalar_one()
  783. resp = await async_client.post(
  784. "/api/v1/auth/oidc/providers",
  785. json={
  786. "name": "DgCreateProvider",
  787. "issuer_url": "https://dgcreate.example.com",
  788. "client_id": "dgcreate-client",
  789. "client_secret": "secret",
  790. "scopes": "openid",
  791. "is_enabled": True,
  792. "auto_create_users": False,
  793. "default_group_id": operators.id,
  794. },
  795. headers=_auth_header(token),
  796. )
  797. assert resp.status_code == 201, resp.text
  798. assert resp.json()["default_group_id"] == operators.id
  799. @pytest.mark.asyncio
  800. @pytest.mark.integration
  801. async def test_create_provider_invalid_default_group_id_returns_422(self, async_client: AsyncClient):
  802. """A default_group_id referencing a non-existent group returns 422."""
  803. token = await _setup_and_login(async_client, "oidcdg_bad", "OidcDgBad1!")
  804. resp = await async_client.post(
  805. "/api/v1/auth/oidc/providers",
  806. json={
  807. "name": "DgBadProvider",
  808. "issuer_url": "https://dgbad.example.com",
  809. "client_id": "dgbad-client",
  810. "client_secret": "secret",
  811. "scopes": "openid",
  812. "is_enabled": True,
  813. "auto_create_users": False,
  814. "default_group_id": 999999,
  815. },
  816. headers=_auth_header(token),
  817. )
  818. assert resp.status_code == 422
  819. @pytest.mark.asyncio
  820. @pytest.mark.integration
  821. async def test_create_provider_omit_default_group_id_stores_null(self, async_client: AsyncClient):
  822. """Omitting default_group_id results in null in the response."""
  823. token = await _setup_and_login(async_client, "oidcdg_null", "OidcDgNull1!")
  824. resp = await async_client.post(
  825. "/api/v1/auth/oidc/providers",
  826. json={
  827. "name": "DgNullProvider",
  828. "issuer_url": "https://dgnull.example.com",
  829. "client_id": "dgnull-client",
  830. "client_secret": "secret",
  831. "scopes": "openid",
  832. "is_enabled": True,
  833. "auto_create_users": False,
  834. },
  835. headers=_auth_header(token),
  836. )
  837. assert resp.status_code == 201, resp.text
  838. assert resp.json()["default_group_id"] is None
  839. @pytest.mark.asyncio
  840. @pytest.mark.integration
  841. async def test_update_provider_default_group_id(self, async_client: AsyncClient, db_session: AsyncSession):
  842. """Updating default_group_id via PUT stores the new value."""
  843. from sqlalchemy import select
  844. from backend.app.models.group import Group
  845. token = await _setup_and_login(async_client, "oidcdg_update", "OidcDgUpdate1!")
  846. create_resp = await async_client.post(
  847. "/api/v1/auth/oidc/providers",
  848. json={
  849. "name": "DgUpdateProvider",
  850. "issuer_url": "https://dgupdate.example.com",
  851. "client_id": "dgupdate-client",
  852. "client_secret": "secret",
  853. "scopes": "openid",
  854. "is_enabled": True,
  855. "auto_create_users": False,
  856. },
  857. headers=_auth_header(token),
  858. )
  859. provider_id = create_resp.json()["id"]
  860. assert create_resp.json()["default_group_id"] is None
  861. grp_result = await db_session.execute(select(Group).where(Group.name == "Operators"))
  862. operators = grp_result.scalar_one()
  863. put_resp = await async_client.put(
  864. f"/api/v1/auth/oidc/providers/{provider_id}",
  865. json={"default_group_id": operators.id},
  866. headers=_auth_header(token),
  867. )
  868. assert put_resp.status_code == 200, put_resp.text
  869. assert put_resp.json()["default_group_id"] == operators.id
  870. @pytest.mark.asyncio
  871. @pytest.mark.integration
  872. async def test_default_group_id_in_public_and_admin_list(self, async_client: AsyncClient, db_session: AsyncSession):
  873. """default_group_id appears in the admin list response.
  874. #3107 review: the public list now serves the slim login-page shape
  875. (id, name, has_icon, is_autologin) so group sync config and other
  876. provider internals stay behind the permission gate — the previous
  877. expectation of default_group_id on the public list is what let the
  878. group mapping leak to anonymous callers.
  879. """
  880. from sqlalchemy import select
  881. from backend.app.models.group import Group
  882. token = await _setup_and_login(async_client, "oidcdg_list", "OidcDgList1!")
  883. grp_result = await db_session.execute(select(Group).where(Group.name == "Operators"))
  884. operators = grp_result.scalar_one()
  885. create_resp = await async_client.post(
  886. "/api/v1/auth/oidc/providers",
  887. json={
  888. "name": "DgListProvider",
  889. "issuer_url": "https://dglist.example.com",
  890. "client_id": "dglist-client",
  891. "client_secret": "secret",
  892. "scopes": "openid",
  893. "is_enabled": True,
  894. "auto_create_users": False,
  895. "default_group_id": operators.id,
  896. },
  897. headers=_auth_header(token),
  898. )
  899. provider_id = create_resp.json()["id"]
  900. all_resp = await async_client.get("/api/v1/auth/oidc/providers/all", headers=_auth_header(token))
  901. match = next((p for p in all_resp.json() if p["id"] == provider_id), None)
  902. assert match is not None
  903. assert match["default_group_id"] == operators.id
  904. pub_resp = await async_client.get("/api/v1/auth/oidc/providers")
  905. pub_match = next((p for p in pub_resp.json() if p["id"] == provider_id), None)
  906. assert pub_match is not None
  907. # Slim public shape: the config fields live on the admin response.
  908. assert set(pub_match.keys()) == {"id", "name", "has_icon", "is_autologin"}
  909. # ===========================================================================
  910. # Security: pre-auth token single-use
  911. # ===========================================================================
  912. class TestPreAuthTokenSingleUse:
  913. """pre_auth_token must be consumed on successful 2FA and cannot be reused."""
  914. @pytest.mark.asyncio
  915. @pytest.mark.integration
  916. async def test_pre_auth_token_is_single_use(self, async_client: AsyncClient):
  917. """A pre_auth_token that was successfully used cannot be reused."""
  918. token = await _setup_and_login(async_client, "singleusepat", "singleusepat1")
  919. setup_resp = await async_client.post("/api/v1/auth/2fa/totp/setup", headers=_auth_header(token))
  920. secret = setup_resp.json()["secret"]
  921. valid_code = pyotp.TOTP(secret).now()
  922. await async_client.post(
  923. "/api/v1/auth/2fa/totp/enable",
  924. json={"code": valid_code},
  925. headers=_auth_header(token),
  926. )
  927. pre_auth_token = await _login_get_pre_auth_token(async_client, "singleusepat", "singleusepat1")
  928. # First use — succeeds
  929. first = await async_client.post(
  930. "/api/v1/auth/2fa/verify",
  931. json={"pre_auth_token": pre_auth_token, "method": "totp", "code": pyotp.TOTP(secret).now()},
  932. )
  933. assert first.status_code == 200
  934. # Second use of the same token — must fail (token already consumed on success)
  935. second = await async_client.post(
  936. "/api/v1/auth/2fa/verify",
  937. json={"pre_auth_token": pre_auth_token, "method": "totp", "code": pyotp.TOTP(secret).now()},
  938. )
  939. assert second.status_code == 401
  940. @pytest.mark.asyncio
  941. @pytest.mark.integration
  942. async def test_pre_auth_token_survives_wrong_code(self, async_client: AsyncClient):
  943. """A wrong 2FA code must NOT burn the pre_auth_token (user can retry)."""
  944. token = await _setup_and_login(async_client, "survivepatuser", "survivepatuser1")
  945. setup_resp = await async_client.post("/api/v1/auth/2fa/totp/setup", headers=_auth_header(token))
  946. secret = setup_resp.json()["secret"]
  947. valid_code = pyotp.TOTP(secret).now()
  948. await async_client.post(
  949. "/api/v1/auth/2fa/totp/enable",
  950. json={"code": valid_code},
  951. headers=_auth_header(token),
  952. )
  953. pre_auth_token = await _login_get_pre_auth_token(async_client, "survivepatuser", "survivepatuser1")
  954. # Wrong code — should fail but not burn the token
  955. bad = await async_client.post(
  956. "/api/v1/auth/2fa/verify",
  957. json={"pre_auth_token": pre_auth_token, "method": "totp", "code": "000000"},
  958. )
  959. assert bad.status_code == 401
  960. # Same token, correct code — should succeed (token still valid)
  961. good = await async_client.post(
  962. "/api/v1/auth/2fa/verify",
  963. json={"pre_auth_token": pre_auth_token, "method": "totp", "code": pyotp.TOTP(secret).now()},
  964. )
  965. assert good.status_code == 200
  966. # ===========================================================================
  967. # Security: cross-user token isolation
  968. # ===========================================================================
  969. class TestCrossUserTokenIsolation:
  970. """A pre_auth_token issued for user A cannot authenticate as user B."""
  971. @pytest.mark.asyncio
  972. @pytest.mark.integration
  973. async def test_token_cannot_be_used_for_different_user(self, async_client: AsyncClient):
  974. """pre_auth_token is bound to the issuing user; using it to verify a different
  975. user's TOTP code must fail."""
  976. # Set up two users with TOTP
  977. token_a = await _setup_and_login(async_client, "crossusera", "crossusera1")
  978. setup_a = await async_client.post("/api/v1/auth/2fa/totp/setup", headers=_auth_header(token_a))
  979. secret_a = setup_a.json()["secret"]
  980. await async_client.post(
  981. "/api/v1/auth/2fa/totp/enable",
  982. json={"code": pyotp.TOTP(secret_a).now()},
  983. headers=_auth_header(token_a),
  984. )
  985. # Get pre_auth_token for user A
  986. pre_auth_a = await _login_get_pre_auth_token(async_client, "crossusera", "crossusera1")
  987. # Try to use user A's token but supply a clearly invalid code — must fail
  988. resp = await async_client.post(
  989. "/api/v1/auth/2fa/verify",
  990. json={"pre_auth_token": pre_auth_a, "method": "totp", "code": "000000"},
  991. )
  992. assert resp.status_code == 401
  993. # ===========================================================================
  994. # Security: admin disable non-admin rejection
  995. # ===========================================================================
  996. class TestAdminDisableNonAdminRejection:
  997. """Non-admin users must be rejected from the admin disable endpoint."""
  998. @pytest.mark.asyncio
  999. @pytest.mark.integration
  1000. async def test_non_admin_cannot_disable_2fa(self, async_client: AsyncClient):
  1001. """A regular (non-admin) user must receive 403 from DELETE /auth/2fa/admin/{id}."""
  1002. # Set up admin, then create a regular user
  1003. admin_token = await _setup_and_login(async_client, "adminusr2fa", "adminusr2fa1")
  1004. # Create a regular user via user management
  1005. create_resp = await async_client.post(
  1006. "/api/v1/users",
  1007. json={"username": "regularusr2fa", "password": "Regularusr2fa1!"},
  1008. headers=_auth_header(admin_token),
  1009. )
  1010. assert create_resp.status_code == 201
  1011. # Login as regular user
  1012. login_resp = await async_client.post(
  1013. LOGIN_URL,
  1014. json={"username": "regularusr2fa", "password": "Regularusr2fa1!"},
  1015. )
  1016. regular_token = login_resp.json()["access_token"]
  1017. # Try to call admin endpoint with the regular user's token
  1018. resp = await async_client.delete(
  1019. f"/api/v1/auth/2fa/admin/{create_resp.json()['id']}",
  1020. headers=_auth_header(regular_token),
  1021. )
  1022. assert resp.status_code == 403
  1023. @pytest.mark.asyncio
  1024. @pytest.mark.integration
  1025. async def test_users_update_permission_alone_is_not_enough(self, async_client: AsyncClient):
  1026. """Editing users is admin-only; so is removing their 2FA."""
  1027. admin_token = await _setup_and_login(async_client, "adminperm2fa", "adminperm2fa1")
  1028. admin_id = (await async_client.get("/api/v1/auth/me", headers=_auth_header(admin_token))).json()["id"]
  1029. group = await async_client.post(
  1030. "/api/v1/groups",
  1031. json={"name": "User managers", "permissions": ["users:read", "users:update"]},
  1032. headers=_auth_header(admin_token),
  1033. )
  1034. assert group.status_code == 201, group.text
  1035. created = await async_client.post(
  1036. "/api/v1/users",
  1037. json={"username": "usermgr2fa", "password": "Usermgr2fa1!", "group_ids": [group.json()["id"]]},
  1038. headers=_auth_header(admin_token),
  1039. )
  1040. assert created.status_code == 201, created.text
  1041. login = await async_client.post(LOGIN_URL, json={"username": "usermgr2fa", "password": "Usermgr2fa1!"})
  1042. manager_token = login.json()["access_token"]
  1043. resp = await async_client.request(
  1044. "DELETE",
  1045. f"/api/v1/auth/2fa/admin/{admin_id}",
  1046. json={"admin_password": "Usermgr2fa1!"},
  1047. headers=_auth_header(manager_token),
  1048. )
  1049. assert resp.status_code == 403
  1050. # ===========================================================================
  1051. # Regenerate backup codes
  1052. # ===========================================================================
  1053. class TestRegenerateBackupCodes:
  1054. """Tests for POST /api/v1/auth/2fa/totp/regenerate-backup-codes."""
  1055. @pytest.mark.asyncio
  1056. @pytest.mark.integration
  1057. async def test_regenerate_requires_totp_enabled(self, async_client: AsyncClient):
  1058. token = await _setup_and_login(async_client, "regennototp", "regennototp1")
  1059. resp = await async_client.post(
  1060. "/api/v1/auth/2fa/totp/regenerate-backup-codes",
  1061. json={"code": "123456"},
  1062. headers=_auth_header(token),
  1063. )
  1064. assert resp.status_code == 400
  1065. @pytest.mark.asyncio
  1066. @pytest.mark.integration
  1067. async def test_regenerate_invalidates_old_codes(self, async_client: AsyncClient):
  1068. """After regenerating, old backup codes must no longer work."""
  1069. token = await _setup_and_login(async_client, "regeninval", "regeninval1")
  1070. setup_resp = await async_client.post("/api/v1/auth/2fa/totp/setup", headers=_auth_header(token))
  1071. secret = setup_resp.json()["secret"]
  1072. enable_resp = await async_client.post(
  1073. "/api/v1/auth/2fa/totp/enable",
  1074. json={"code": pyotp.TOTP(secret).now()},
  1075. headers=_auth_header(token),
  1076. )
  1077. old_backup = enable_resp.json()["backup_codes"][0]
  1078. # Regenerate backup codes
  1079. regen_resp = await async_client.post(
  1080. "/api/v1/auth/2fa/totp/regenerate-backup-codes",
  1081. json={"code": pyotp.TOTP(secret).now()},
  1082. headers=_auth_header(token),
  1083. )
  1084. assert regen_resp.status_code == 200
  1085. new_codes = regen_resp.json()["backup_codes"]
  1086. assert len(new_codes) == 10
  1087. assert old_backup not in new_codes
  1088. # Old backup code must now fail
  1089. pre_auth_token = await _login_get_pre_auth_token(async_client, "regeninval", "regeninval1")
  1090. fail_resp = await async_client.post(
  1091. "/api/v1/auth/2fa/verify",
  1092. json={"pre_auth_token": pre_auth_token, "method": "backup", "code": old_backup},
  1093. )
  1094. assert fail_resp.status_code == 401
  1095. @pytest.mark.asyncio
  1096. @pytest.mark.integration
  1097. async def test_regenerate_with_invalid_code_fails(self, async_client: AsyncClient):
  1098. token = await _setup_and_login(async_client, "regeninvcode", "regeninvcode1")
  1099. setup_resp = await async_client.post("/api/v1/auth/2fa/totp/setup", headers=_auth_header(token))
  1100. secret = setup_resp.json()["secret"]
  1101. await async_client.post(
  1102. "/api/v1/auth/2fa/totp/enable",
  1103. json={"code": pyotp.TOTP(secret).now()},
  1104. headers=_auth_header(token),
  1105. )
  1106. resp = await async_client.post(
  1107. "/api/v1/auth/2fa/totp/regenerate-backup-codes",
  1108. json={"code": "000000"},
  1109. headers=_auth_header(token),
  1110. )
  1111. assert resp.status_code == 400
  1112. # ===========================================================================
  1113. # Security: method field validation
  1114. # ===========================================================================
  1115. class TestVerifyMethodValidation:
  1116. """The method field must be one of totp/email/backup (Pydantic Literal)."""
  1117. @pytest.mark.asyncio
  1118. @pytest.mark.integration
  1119. async def test_invalid_method_rejected_by_schema(self, async_client: AsyncClient):
  1120. """Pydantic should reject unknown method values with 422."""
  1121. resp = await async_client.post(
  1122. "/api/v1/auth/2fa/verify",
  1123. json={"pre_auth_token": "anytoken", "code": "123456", "method": "sms"},
  1124. )
  1125. assert resp.status_code == 422
  1126. @pytest.mark.asyncio
  1127. @pytest.mark.integration
  1128. async def test_oversized_pre_auth_token_rejected(self, async_client: AsyncClient):
  1129. """pre_auth_token exceeding max_length=128 should be rejected with 422."""
  1130. resp = await async_client.post(
  1131. "/api/v1/auth/2fa/verify",
  1132. json={"pre_auth_token": "x" * 200, "code": "123456", "method": "totp"},
  1133. )
  1134. assert resp.status_code == 422
  1135. # ===========================================================================
  1136. # Login response shape for 2FA users
  1137. # ===========================================================================
  1138. class TestLoginResponseShape:
  1139. """Login for a 2FA-enabled user must return requires_2fa+pre_auth_token
  1140. and must NOT include access_token (which would bypass the 2FA gate)."""
  1141. @pytest.mark.asyncio
  1142. @pytest.mark.integration
  1143. async def test_login_2fa_user_omits_access_token(self, async_client: AsyncClient):
  1144. """A user with TOTP enabled must not receive an access_token on /auth/login."""
  1145. token = await _setup_and_login(async_client, "loginshape", "loginshape1")
  1146. setup_resp = await async_client.post("/api/v1/auth/2fa/totp/setup", headers=_auth_header(token))
  1147. secret = setup_resp.json()["secret"]
  1148. await async_client.post(
  1149. "/api/v1/auth/2fa/totp/enable",
  1150. json={"code": pyotp.TOTP(secret).now()},
  1151. headers=_auth_header(token),
  1152. )
  1153. login_resp = await async_client.post(LOGIN_URL, json={"username": "loginshape", "password": "Loginshape1!"})
  1154. assert login_resp.status_code == 200
  1155. data = login_resp.json()
  1156. assert data.get("requires_2fa") is True
  1157. assert data.get("pre_auth_token") is not None
  1158. # access_token must NOT be present — it would bypass the 2FA gate
  1159. assert "access_token" not in data or data["access_token"] is None
  1160. # ===========================================================================
  1161. # TOTP replay protection
  1162. # ===========================================================================
  1163. async def _setup_totp_user(client: AsyncClient, username: str, password: str) -> tuple[str, str]:
  1164. """Create user, set up and enable TOTP; return (bearer_token, totp_secret)."""
  1165. token = await _setup_and_login(client, username, password)
  1166. setup_resp = await client.post("/api/v1/auth/2fa/totp/setup", headers=_auth_header(token))
  1167. secret = setup_resp.json()["secret"]
  1168. await client.post(
  1169. "/api/v1/auth/2fa/totp/enable",
  1170. json={"code": pyotp.TOTP(secret).now()},
  1171. headers=_auth_header(token),
  1172. )
  1173. return token, secret
  1174. class TestTOTPReplay:
  1175. """The same TOTP code must not be accepted twice within one 30-second window."""
  1176. @pytest.mark.asyncio
  1177. @pytest.mark.integration
  1178. async def test_totp_replay_rejected_on_verify(self, async_client: AsyncClient):
  1179. """Replaying the same code on /2fa/verify must return 400."""
  1180. _token, secret = await _setup_totp_user(async_client, "replayverify", "replayverify1")
  1181. code = pyotp.TOTP(secret).now()
  1182. pre_auth = await _login_get_pre_auth_token(async_client, "replayverify", "replayverify1")
  1183. first = await async_client.post(
  1184. "/api/v1/auth/2fa/verify",
  1185. json={"pre_auth_token": pre_auth, "method": "totp", "code": code},
  1186. )
  1187. assert first.status_code == 200
  1188. # Second login to get a fresh pre_auth_token (first was consumed)
  1189. pre_auth2 = await _login_get_pre_auth_token(async_client, "replayverify", "replayverify1")
  1190. second = await async_client.post(
  1191. "/api/v1/auth/2fa/verify",
  1192. json={"pre_auth_token": pre_auth2, "method": "totp", "code": code},
  1193. )
  1194. assert second.status_code == 400
  1195. @staticmethod
  1196. def _freeze_mfa_clock(monkeypatch, at: float) -> type:
  1197. """Pin the clock the MFA routes read; set ``clock.at`` to move it."""
  1198. from backend.app.api.routes import mfa as mfa_module
  1199. class Clock(datetime):
  1200. @classmethod
  1201. def now(cls, tz=None):
  1202. return datetime.fromtimestamp(Clock.at, tz)
  1203. Clock.at = at
  1204. monkeypatch.setattr(mfa_module, "datetime", Clock)
  1205. return Clock
  1206. async def _verify(self, client: AsyncClient, username: str, password: str, code: str):
  1207. pre_auth = await _login_get_pre_auth_token(client, username, password)
  1208. return await client.post(
  1209. "/api/v1/auth/2fa/verify",
  1210. json={"pre_auth_token": pre_auth, "method": "totp", "code": code},
  1211. )
  1212. @pytest.mark.asyncio
  1213. @pytest.mark.integration
  1214. async def test_totp_replay_in_the_next_step_is_rejected(self, async_client: AsyncClient, monkeypatch):
  1215. """A code stays valid into the next 30-second step; its reuse there must still fail."""
  1216. _token, secret = await _setup_totp_user(async_client, "replaynext", "replaynext1")
  1217. totp = pyotp.TOTP(secret)
  1218. step_start = (int(time.time()) // 30 + 1) * 30
  1219. code = totp.at(step_start)
  1220. clock = self._freeze_mfa_clock(monkeypatch, step_start + 29)
  1221. first = await self._verify(async_client, "replaynext", "replaynext1", code)
  1222. assert first.status_code == 200
  1223. clock.at = step_start + 31
  1224. second = await self._verify(async_client, "replaynext", "replaynext1", code)
  1225. assert second.status_code == 400
  1226. assert second.json()["detail"] == "TOTP code already used"
  1227. @pytest.mark.asyncio
  1228. @pytest.mark.integration
  1229. async def test_a_previous_step_code_records_its_own_step(self, async_client: AsyncClient, monkeypatch):
  1230. """Used first in the next step, the code is recorded under its own step: the
  1231. current step's code is still accepted afterwards, the replay is not."""
  1232. _token, secret = await _setup_totp_user(async_client, "prevstep", "prevstep12")
  1233. totp = pyotp.TOTP(secret)
  1234. step_start = (int(time.time()) // 30 + 1) * 30
  1235. old_code, new_code = totp.at(step_start), totp.at(step_start + 30)
  1236. self._freeze_mfa_clock(monkeypatch, step_start + 35)
  1237. assert (await self._verify(async_client, "prevstep", "prevstep12", old_code)).status_code == 200
  1238. assert (await self._verify(async_client, "prevstep", "prevstep12", old_code)).status_code == 400
  1239. assert (await self._verify(async_client, "prevstep", "prevstep12", new_code)).status_code == 200
  1240. @pytest.mark.asyncio
  1241. @pytest.mark.integration
  1242. async def test_a_code_two_steps_old_is_rejected(self, async_client: AsyncClient, monkeypatch):
  1243. _token, secret = await _setup_totp_user(async_client, "stalecode", "stalecode12")
  1244. step_start = (int(time.time()) // 30 + 1) * 30
  1245. code = pyotp.TOTP(secret).at(step_start)
  1246. self._freeze_mfa_clock(monkeypatch, step_start + 61)
  1247. resp = await self._verify(async_client, "stalecode", "stalecode12", code)
  1248. assert resp.status_code == 401
  1249. @pytest.mark.asyncio
  1250. @pytest.mark.integration
  1251. async def test_totp_replay_rejected_on_disable(self, async_client: AsyncClient):
  1252. """A code already used in verify_2fa must be rejected on /2fa/totp/disable."""
  1253. _setup_token, secret = await _setup_totp_user(async_client, "replaydisable", "replaydisable1")
  1254. code = pyotp.TOTP(secret).now()
  1255. # Use the code in verify_2fa — this sets last_totp_counter in DB
  1256. pre_auth = await _login_get_pre_auth_token(async_client, "replaydisable", "replaydisable1")
  1257. verify_resp = await async_client.post(
  1258. "/api/v1/auth/2fa/verify",
  1259. json={"pre_auth_token": pre_auth, "method": "totp", "code": code},
  1260. )
  1261. assert verify_resp.status_code == 200
  1262. authed_token = verify_resp.json()["access_token"]
  1263. # Replay the same code on disable — must be rejected (same 30-second window)
  1264. disable_resp = await async_client.post(
  1265. "/api/v1/auth/2fa/totp/disable",
  1266. json={"code": code},
  1267. headers=_auth_header(authed_token),
  1268. )
  1269. assert disable_resp.status_code == 400
  1270. # ===========================================================================
  1271. # Rate limiting on disable_totp and regenerate_backup_codes (I10)
  1272. # ===========================================================================
  1273. class TestRateLimitingDisableRegenerate:
  1274. """disable_totp and regenerate_backup_codes must enforce rate limiting (I10)."""
  1275. @pytest.mark.asyncio
  1276. @pytest.mark.integration
  1277. async def test_disable_totp_rate_limited_after_failures(self, async_client: AsyncClient):
  1278. """Repeated wrong codes on /2fa/totp/disable trigger 429."""
  1279. token, _secret = await _setup_totp_user(async_client, "rldisable", "rldisable1")
  1280. for _ in range(5):
  1281. await async_client.post(
  1282. "/api/v1/auth/2fa/totp/disable",
  1283. json={"code": "000000"},
  1284. headers=_auth_header(token),
  1285. )
  1286. resp = await async_client.post(
  1287. "/api/v1/auth/2fa/totp/disable",
  1288. json={"code": "000000"},
  1289. headers=_auth_header(token),
  1290. )
  1291. assert resp.status_code == 429
  1292. @pytest.mark.asyncio
  1293. @pytest.mark.integration
  1294. async def test_regenerate_backup_codes_rate_limited_after_failures(self, async_client: AsyncClient):
  1295. """Repeated wrong codes on /2fa/totp/regenerate-backup-codes trigger 429."""
  1296. token, _secret = await _setup_totp_user(async_client, "rlregen", "rlregen1")
  1297. for _ in range(5):
  1298. await async_client.post(
  1299. "/api/v1/auth/2fa/totp/regenerate-backup-codes",
  1300. json={"code": "000000"},
  1301. headers=_auth_header(token),
  1302. )
  1303. resp = await async_client.post(
  1304. "/api/v1/auth/2fa/totp/regenerate-backup-codes",
  1305. json={"code": "000000"},
  1306. headers=_auth_header(token),
  1307. )
  1308. assert resp.status_code == 429
  1309. # ===========================================================================
  1310. # Email OTP send → verify end-to-end (coverage gap C3)
  1311. # ===========================================================================
  1312. class TestEmailOTPSendVerify:
  1313. """Full email OTP login: send code → verify code → JWT."""
  1314. @pytest.mark.asyncio
  1315. @pytest.mark.integration
  1316. async def test_email_otp_send_and_verify(self, async_client: AsyncClient, db_session: AsyncSession):
  1317. """login → POST /2fa/email/send (patched SMTP) → POST /2fa/verify → JWT."""
  1318. import re
  1319. from unittest.mock import AsyncMock, MagicMock, patch
  1320. from sqlalchemy import select as sa_select
  1321. token = await _setup_and_login(async_client, "emailsendok", "emailsendok1")
  1322. # Give the user an email address
  1323. result = await db_session.execute(sa_select(User).where(User.username == "emailsendok"))
  1324. user = result.scalar_one()
  1325. user.email = "emailsendok@example.com"
  1326. await db_session.commit()
  1327. # Enable email OTP via DB injection
  1328. setup_code = "444444"
  1329. setup_token = secrets.token_urlsafe(32)
  1330. db_session.add(
  1331. AuthEphemeralToken(
  1332. token=setup_token,
  1333. token_type="email_otp_setup",
  1334. username="emailsendok",
  1335. nonce=_pwd_context.hash(setup_code),
  1336. expires_at=datetime.now(timezone.utc) + timedelta(minutes=10),
  1337. )
  1338. )
  1339. await db_session.commit()
  1340. await async_client.post(
  1341. "/api/v1/auth/2fa/email/enable/confirm",
  1342. json={"setup_token": setup_token, "code": setup_code},
  1343. headers=_auth_header(token),
  1344. )
  1345. # Login now requires 2FA — get pre_auth_token (cookie set automatically)
  1346. pre_auth_token = await _login_get_pre_auth_token(async_client, "emailsendok", "emailsendok1")
  1347. # Mock SMTP and capture the sent OTP code
  1348. captured: dict[str, str] = {}
  1349. smtp_settings_mock = MagicMock()
  1350. def _capture_email(smtp_settings, to_email, subject, body_text, body_html):
  1351. m = re.search(r"login code is: (\d{6})", body_text)
  1352. if m:
  1353. captured["otp"] = m.group(1)
  1354. with (
  1355. patch("backend.app.api.routes.mfa.get_smtp_settings", new=AsyncMock(return_value=smtp_settings_mock)),
  1356. patch("backend.app.api.routes.mfa.send_email", side_effect=_capture_email),
  1357. ):
  1358. send_resp = await async_client.post(
  1359. "/api/v1/auth/2fa/email/send",
  1360. json={"pre_auth_token": pre_auth_token},
  1361. )
  1362. assert send_resp.status_code == 200, send_resp.text
  1363. fresh_token = send_resp.json()["pre_auth_token"]
  1364. assert "otp" in captured, "send_email was not called or code not found in body"
  1365. # Verify with the captured OTP code — cookie still in the async_client jar
  1366. verify_resp = await async_client.post(
  1367. "/api/v1/auth/2fa/verify",
  1368. json={"pre_auth_token": fresh_token, "method": "email", "code": captured["otp"]},
  1369. )
  1370. assert verify_resp.status_code == 200
  1371. data = verify_resp.json()
  1372. assert "access_token" in data
  1373. assert data["user"]["username"] == "emailsendok"
  1374. @pytest.mark.asyncio
  1375. @pytest.mark.integration
  1376. async def test_email_otp_wrong_code_rejected(self, async_client: AsyncClient, db_session: AsyncSession):
  1377. """A wrong email OTP code must return 401 without burning the pre_auth_token."""
  1378. from unittest.mock import AsyncMock, MagicMock, patch
  1379. from sqlalchemy import select as sa_select
  1380. token = await _setup_and_login(async_client, "emailwrongcode", "emailwrongcode1")
  1381. result = await db_session.execute(sa_select(User).where(User.username == "emailwrongcode"))
  1382. user = result.scalar_one()
  1383. user.email = "emailwrongcode@example.com"
  1384. await db_session.commit()
  1385. setup_code = "555555"
  1386. setup_token = secrets.token_urlsafe(32)
  1387. db_session.add(
  1388. AuthEphemeralToken(
  1389. token=setup_token,
  1390. token_type="email_otp_setup",
  1391. username="emailwrongcode",
  1392. nonce=_pwd_context.hash(setup_code),
  1393. expires_at=datetime.now(timezone.utc) + timedelta(minutes=10),
  1394. )
  1395. )
  1396. await db_session.commit()
  1397. await async_client.post(
  1398. "/api/v1/auth/2fa/email/enable/confirm",
  1399. json={"setup_token": setup_token, "code": setup_code},
  1400. headers=_auth_header(token),
  1401. )
  1402. pre_auth_token = await _login_get_pre_auth_token(async_client, "emailwrongcode", "emailwrongcode1")
  1403. captured: dict[str, str] = {}
  1404. smtp_mock = MagicMock()
  1405. def _capture(smtp_settings, to_email, subject, body_text, body_html):
  1406. import re
  1407. m = re.search(r"login code is: (\d{6})", body_text)
  1408. if m:
  1409. captured["otp"] = m.group(1)
  1410. with (
  1411. patch("backend.app.api.routes.mfa.get_smtp_settings", new=AsyncMock(return_value=smtp_mock)),
  1412. patch("backend.app.api.routes.mfa.send_email", side_effect=_capture),
  1413. ):
  1414. send_resp = await async_client.post(
  1415. "/api/v1/auth/2fa/email/send",
  1416. json={"pre_auth_token": pre_auth_token},
  1417. )
  1418. assert send_resp.status_code == 200
  1419. fresh_token = send_resp.json()["pre_auth_token"]
  1420. # Wrong code → 401
  1421. bad = await async_client.post(
  1422. "/api/v1/auth/2fa/verify",
  1423. json={"pre_auth_token": fresh_token, "method": "email", "code": "000000"},
  1424. )
  1425. assert bad.status_code == 401
  1426. # Correct code still works (token not burned by wrong attempt)
  1427. good = await async_client.post(
  1428. "/api/v1/auth/2fa/verify",
  1429. json={"pre_auth_token": fresh_token, "method": "email", "code": captured["otp"]},
  1430. )
  1431. assert good.status_code == 200
  1432. # ===========================================================================
  1433. # OIDC end-to-end (coverage gap C4)
  1434. # ===========================================================================
  1435. def _make_test_rsa_key():
  1436. """Generate a throwaway RSA key pair and a matching JWK set for tests."""
  1437. import base64
  1438. from cryptography.hazmat.primitives import serialization
  1439. from cryptography.hazmat.primitives.asymmetric import rsa
  1440. private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
  1441. private_pem = private_key.private_bytes(
  1442. serialization.Encoding.PEM,
  1443. serialization.PrivateFormat.TraditionalOpenSSL,
  1444. serialization.NoEncryption(),
  1445. )
  1446. pub_numbers = private_key.public_key().public_numbers()
  1447. def _b64url(n: int, length: int) -> str:
  1448. return base64.urlsafe_b64encode(n.to_bytes(length, "big")).rstrip(b"=").decode()
  1449. jwks = {
  1450. "keys": [
  1451. {
  1452. "kty": "RSA",
  1453. "use": "sig",
  1454. "alg": "RS256",
  1455. "kid": "test-kid-1",
  1456. "n": _b64url(pub_numbers.n, 256),
  1457. "e": _b64url(pub_numbers.e, 3),
  1458. }
  1459. ]
  1460. }
  1461. return private_pem, jwks
  1462. class TestOIDCEndToEnd:
  1463. """Full OIDC auth-code flow: state → callback (mocked IdP) → exchange → JWT."""
  1464. @pytest.mark.asyncio
  1465. @pytest.mark.integration
  1466. async def test_oidc_callback_creates_user_and_issues_jwt(self, async_client: AsyncClient, db_session: AsyncSession):
  1467. """callback validates the mocked ID token, creates a user, and redirects
  1468. with an oidc_exchange token; exchanging that token returns a full JWT."""
  1469. import time
  1470. from unittest.mock import patch
  1471. import jwt as pyjwt
  1472. private_pem, jwks_data = _make_test_rsa_key()
  1473. issuer = "https://idp.test.example.com"
  1474. client_id = "oidc-test-client"
  1475. nonce = secrets.token_urlsafe(16)
  1476. now = int(time.time())
  1477. id_token = pyjwt.encode(
  1478. {
  1479. "sub": "oidc-sub-e2e",
  1480. "iss": issuer,
  1481. "aud": client_id,
  1482. "nonce": nonce,
  1483. "email": "oidce2e@example.com",
  1484. "email_verified": True,
  1485. "iat": now,
  1486. "exp": now + 300,
  1487. },
  1488. private_pem,
  1489. algorithm="RS256",
  1490. headers={"kid": "test-kid-1"},
  1491. )
  1492. # Create OIDC provider
  1493. admin_token = await _setup_and_login(async_client, "oidce2eadm", "oidce2eadm1")
  1494. create_resp = await async_client.post(
  1495. "/api/v1/auth/oidc/providers",
  1496. json={
  1497. "name": "E2E-IdP",
  1498. "issuer_url": issuer,
  1499. "client_id": client_id,
  1500. "client_secret": "test-secret",
  1501. "scopes": "openid email profile",
  1502. "is_enabled": True,
  1503. "auto_create_users": True,
  1504. },
  1505. headers=_auth_header(admin_token),
  1506. )
  1507. assert create_resp.status_code == 201
  1508. provider_id = create_resp.json()["id"]
  1509. # Simulate the authorize step: insert an oidc_state token directly
  1510. state = secrets.token_urlsafe(32)
  1511. code_verifier = secrets.token_urlsafe(48)
  1512. db_session.add(
  1513. AuthEphemeralToken(
  1514. token=state,
  1515. token_type="oidc_state",
  1516. provider_id=provider_id,
  1517. nonce=nonce,
  1518. code_verifier=code_verifier,
  1519. expires_at=datetime.now(timezone.utc) + timedelta(minutes=5),
  1520. )
  1521. )
  1522. await db_session.commit()
  1523. # Mock httpx calls made inside oidc_callback
  1524. discovery_doc = {
  1525. "issuer": issuer,
  1526. "authorization_endpoint": f"{issuer}/auth",
  1527. "token_endpoint": f"{issuer}/token",
  1528. "jwks_uri": f"{issuer}/.well-known/jwks.json",
  1529. }
  1530. token_response = {
  1531. "access_token": "mock-access",
  1532. "token_type": "Bearer",
  1533. "id_token": id_token,
  1534. }
  1535. class _MockResp:
  1536. def __init__(self, data):
  1537. self._data = data
  1538. self.status_code = 200
  1539. self.is_success = True
  1540. self.text = str(data)
  1541. def json(self):
  1542. return self._data
  1543. def raise_for_status(self):
  1544. pass
  1545. class _MockHttpxClient:
  1546. def __init__(self, *args, **kwargs):
  1547. pass
  1548. async def __aenter__(self):
  1549. return self
  1550. async def __aexit__(self, *args):
  1551. pass
  1552. async def get(self, url, **kwargs):
  1553. if "jwks" in url:
  1554. return _MockResp(jwks_data)
  1555. return _MockResp(discovery_doc)
  1556. async def post(self, url, **kwargs):
  1557. return _MockResp(token_response)
  1558. with patch("backend.app.api.routes.mfa.httpx.AsyncClient", _MockHttpxClient):
  1559. callback_resp = await async_client.get(
  1560. f"/api/v1/auth/oidc/callback?code=test-auth-code&state={state}",
  1561. follow_redirects=False,
  1562. )
  1563. assert callback_resp.status_code == 302, callback_resp.text
  1564. location = callback_resp.headers.get("location", "")
  1565. assert "oidc_token=" in location, f"Expected oidc_token in redirect, got: {location}"
  1566. # Extract and exchange the oidc_exchange token
  1567. oidc_exchange_token = location.split("oidc_token=")[1].split("&")[0]
  1568. exchange_resp = await async_client.post(
  1569. "/api/v1/auth/oidc/exchange",
  1570. json={"oidc_token": oidc_exchange_token},
  1571. )
  1572. assert exchange_resp.status_code == 200
  1573. data = exchange_resp.json()
  1574. assert "access_token" in data
  1575. assert data["user"]["username"] is not None
  1576. @pytest.mark.asyncio
  1577. @pytest.mark.integration
  1578. async def test_oidc_callback_invalid_state_redirects_error(self, async_client: AsyncClient):
  1579. """An unknown state token must redirect to /?oidc_error=invalid_state."""
  1580. resp = await async_client.get(
  1581. "/api/v1/auth/oidc/callback?code=x&state=totally-bogus-state",
  1582. follow_redirects=False,
  1583. )
  1584. assert resp.status_code == 302
  1585. assert "invalid_state" in resp.headers.get("location", "")
  1586. @pytest.mark.asyncio
  1587. @pytest.mark.integration
  1588. async def test_oidc_state_is_single_use(self, async_client: AsyncClient, db_session: AsyncSession):
  1589. """Replaying the same state token must fail on the second callback."""
  1590. import time
  1591. from unittest.mock import patch
  1592. import jwt as pyjwt
  1593. private_pem, jwks_data = _make_test_rsa_key()
  1594. issuer = "https://idp2.test.example.com"
  1595. client_id = "oidc-client-2"
  1596. nonce = secrets.token_urlsafe(16)
  1597. now = int(time.time())
  1598. id_token = pyjwt.encode(
  1599. {
  1600. "sub": "sub-single-use",
  1601. "iss": issuer,
  1602. "aud": client_id,
  1603. "nonce": nonce,
  1604. "email": "su@example.com",
  1605. "email_verified": True,
  1606. "iat": now,
  1607. "exp": now + 300,
  1608. },
  1609. private_pem,
  1610. algorithm="RS256",
  1611. headers={"kid": "test-kid-1"},
  1612. )
  1613. admin_token = await _setup_and_login(async_client, "oidcsuadm", "oidcsuadm1")
  1614. cr = await async_client.post(
  1615. "/api/v1/auth/oidc/providers",
  1616. json={
  1617. "name": "SU-IdP",
  1618. "issuer_url": issuer,
  1619. "client_id": client_id,
  1620. "client_secret": "s",
  1621. "scopes": "openid",
  1622. "is_enabled": True,
  1623. "auto_create_users": True,
  1624. },
  1625. headers=_auth_header(admin_token),
  1626. )
  1627. provider_id = cr.json()["id"]
  1628. state = secrets.token_urlsafe(32)
  1629. db_session.add(
  1630. AuthEphemeralToken(
  1631. token=state,
  1632. token_type="oidc_state",
  1633. provider_id=provider_id,
  1634. nonce=nonce,
  1635. code_verifier=secrets.token_urlsafe(48),
  1636. expires_at=datetime.now(timezone.utc) + timedelta(minutes=5),
  1637. )
  1638. )
  1639. await db_session.commit()
  1640. discovery_doc = {
  1641. "issuer": issuer,
  1642. "authorization_endpoint": f"{issuer}/auth",
  1643. "token_endpoint": f"{issuer}/token",
  1644. "jwks_uri": f"{issuer}/.well-known/jwks.json",
  1645. }
  1646. token_response = {"access_token": "a", "token_type": "Bearer", "id_token": id_token}
  1647. class _MockResp:
  1648. def __init__(self, data):
  1649. self._data = data
  1650. self.status_code = 200
  1651. self.is_success = True
  1652. self.text = str(data)
  1653. def json(self):
  1654. return self._data
  1655. def raise_for_status(self):
  1656. pass
  1657. class _MockHttpxClient:
  1658. def __init__(self, *a, **kw):
  1659. pass
  1660. async def __aenter__(self):
  1661. return self
  1662. async def __aexit__(self, *a):
  1663. pass
  1664. async def get(self, url, **kw):
  1665. return _MockResp(jwks_data if "jwks" in url else discovery_doc)
  1666. async def post(self, url, **kw):
  1667. return _MockResp(token_response)
  1668. with patch("backend.app.api.routes.mfa.httpx.AsyncClient", _MockHttpxClient):
  1669. first = await async_client.get(
  1670. f"/api/v1/auth/oidc/callback?code=c&state={state}",
  1671. follow_redirects=False,
  1672. )
  1673. assert first.status_code == 302
  1674. assert "oidc_token=" in first.headers.get("location", "")
  1675. # Replay: second callback with the same state must fail
  1676. second = await async_client.get(
  1677. f"/api/v1/auth/oidc/callback?code=c&state={state}",
  1678. follow_redirects=False,
  1679. )
  1680. assert second.status_code == 302
  1681. assert "invalid_state" in second.headers.get("location", "")
  1682. # ===========================================================================
  1683. # H-2: Wrong code must NOT consume the email OTP setup token (peek-then-consume)
  1684. # ===========================================================================
  1685. class TestEmailOTPSetupTokenPreservedOnWrongCode:
  1686. """After H-2 fix: a wrong code leaves the setup token intact so the user can retry."""
  1687. @pytest.mark.asyncio
  1688. @pytest.mark.integration
  1689. async def test_wrong_code_does_not_consume_setup_token(self, async_client: AsyncClient, db_session: AsyncSession):
  1690. """Wrong code returns 400 but the setup token survives; correct code then works."""
  1691. token = await _setup_and_login(async_client, "h2retryuser", "h2retrypass1")
  1692. code = "999999"
  1693. code_hash = _pwd_context.hash(code)
  1694. setup_token = secrets.token_urlsafe(32)
  1695. db_session.add(
  1696. AuthEphemeralToken(
  1697. token=setup_token,
  1698. token_type="email_otp_setup",
  1699. username="h2retryuser",
  1700. nonce=code_hash,
  1701. expires_at=datetime.now(timezone.utc) + timedelta(minutes=10),
  1702. )
  1703. )
  1704. await db_session.commit()
  1705. # First attempt: wrong code → 400
  1706. wrong = await async_client.post(
  1707. "/api/v1/auth/2fa/email/enable/confirm",
  1708. json={"setup_token": setup_token, "code": "000000"},
  1709. headers=_auth_header(token),
  1710. )
  1711. assert wrong.status_code == 400
  1712. # Second attempt: correct code → must succeed (token was NOT consumed)
  1713. correct = await async_client.post(
  1714. "/api/v1/auth/2fa/email/enable/confirm",
  1715. json={"setup_token": setup_token, "code": code},
  1716. headers=_auth_header(token),
  1717. )
  1718. assert correct.status_code == 200
  1719. # ===========================================================================
  1720. # M-2: New OIDC provider must default to auto_link_existing_accounts=False
  1721. # ===========================================================================
  1722. class TestOIDCProviderAutoLinkDefault:
  1723. """auto_link_existing_accounts must default to False (M-2 fix)."""
  1724. @pytest.mark.asyncio
  1725. @pytest.mark.integration
  1726. async def test_new_provider_auto_link_defaults_to_false(self, async_client: AsyncClient):
  1727. token = await _setup_and_login(async_client, "m2autolinkadmin", "m2autolinkadmin1")
  1728. resp = await async_client.post(
  1729. "/api/v1/auth/oidc/providers",
  1730. json={
  1731. "name": "AutoLinkTest",
  1732. "issuer_url": "https://autolink.example.com",
  1733. "client_id": "alc",
  1734. "client_secret": "als",
  1735. "scopes": "openid",
  1736. "is_enabled": True,
  1737. "auto_create_users": False,
  1738. # auto_link_existing_accounts intentionally omitted
  1739. },
  1740. headers=_auth_header(token),
  1741. )
  1742. assert resp.status_code == 201
  1743. assert resp.json()["auto_link_existing_accounts"] is False
  1744. # ===========================================================================
  1745. # L-5: 2FA verify code format validation
  1746. # ===========================================================================
  1747. class TestTwoFAVerifyCodeFormat:
  1748. """TwoFAVerifyRequest.code must be 6–8 alphanumeric characters (L-5)."""
  1749. @pytest.mark.asyncio
  1750. @pytest.mark.integration
  1751. async def test_code_too_long_rejected(self, async_client: AsyncClient):
  1752. """code > 8 characters must be rejected with 422."""
  1753. resp = await async_client.post(
  1754. "/api/v1/auth/2fa/verify",
  1755. json={"pre_auth_token": "anytoken", "code": "1" * 9, "method": "totp"},
  1756. )
  1757. assert resp.status_code == 422
  1758. @pytest.mark.asyncio
  1759. @pytest.mark.integration
  1760. async def test_code_non_alphanumeric_rejected(self, async_client: AsyncClient):
  1761. """code containing non-alphanumeric chars must be rejected with 422."""
  1762. resp = await async_client.post(
  1763. "/api/v1/auth/2fa/verify",
  1764. json={"pre_auth_token": "anytoken", "code": "12-456", "method": "totp"},
  1765. )
  1766. assert resp.status_code == 422
  1767. @pytest.mark.asyncio
  1768. @pytest.mark.integration
  1769. async def test_code_too_short_rejected(self, async_client: AsyncClient):
  1770. """code < 6 characters must be rejected with 422."""
  1771. resp = await async_client.post(
  1772. "/api/v1/auth/2fa/verify",
  1773. json={"pre_auth_token": "anytoken", "code": "12345", "method": "totp"},
  1774. )
  1775. assert resp.status_code == 422
  1776. @pytest.mark.asyncio
  1777. @pytest.mark.integration
  1778. async def test_code_exactly_6_passes_schema(self, async_client: AsyncClient):
  1779. """6-character alphanumeric code passes schema (may fail 2FA logic with 400)."""
  1780. resp = await async_client.post(
  1781. "/api/v1/auth/2fa/verify",
  1782. json={"pre_auth_token": "x" * 32, "code": "123456", "method": "totp"},
  1783. )
  1784. assert resp.status_code != 422
  1785. @pytest.mark.asyncio
  1786. @pytest.mark.integration
  1787. async def test_code_exactly_8_passes_schema(self, async_client: AsyncClient):
  1788. """8-character alphanumeric backup code passes schema."""
  1789. resp = await async_client.post(
  1790. "/api/v1/auth/2fa/verify",
  1791. json={"pre_auth_token": "x" * 32, "code": "ABCD1234", "method": "backup"},
  1792. )
  1793. assert resp.status_code != 422
  1794. # ===========================================================================
  1795. # M-NEW-1: verify_slicer_download_token must NOT consume token on wrong resource
  1796. # ===========================================================================
  1797. class TestSlicerTokenResourceBinding:
  1798. """Token for resource A must survive a wrong-resource check and still work for A."""
  1799. @pytest.mark.asyncio
  1800. @pytest.mark.integration
  1801. async def test_wrong_resource_does_not_consume_token(self, async_client: AsyncClient, db_session: AsyncSession):
  1802. """A slicer token bound to archive:5 must NOT be consumed when checked against archive:6."""
  1803. from datetime import datetime, timedelta, timezone
  1804. from backend.app.core.auth import verify_slicer_download_token
  1805. from backend.app.models.auth_ephemeral import AuthEphemeralToken
  1806. now = datetime.now(timezone.utc)
  1807. token_val = secrets.token_urlsafe(24)
  1808. db_session.add(
  1809. AuthEphemeralToken(
  1810. token=token_val,
  1811. token_type="slicer_download",
  1812. nonce="archive:5",
  1813. expires_at=now + timedelta(minutes=5),
  1814. )
  1815. )
  1816. await db_session.commit()
  1817. # Wrong resource → must return False and NOT consume the token
  1818. wrong = await verify_slicer_download_token(token_val, "archive", 6)
  1819. assert wrong is False
  1820. # Correct resource → must return True (token survived the wrong-resource check)
  1821. correct = await verify_slicer_download_token(token_val, "archive", 5)
  1822. assert correct is True
  1823. @pytest.mark.asyncio
  1824. @pytest.mark.integration
  1825. async def test_correct_resource_consumes_token(self, async_client: AsyncClient, db_session: AsyncSession):
  1826. """A slicer token is single-use: second correct-resource check must return False."""
  1827. from datetime import datetime, timedelta, timezone
  1828. from backend.app.core.auth import verify_slicer_download_token
  1829. from backend.app.models.auth_ephemeral import AuthEphemeralToken
  1830. now = datetime.now(timezone.utc)
  1831. token_val = secrets.token_urlsafe(24)
  1832. db_session.add(
  1833. AuthEphemeralToken(
  1834. token=token_val,
  1835. token_type="slicer_download",
  1836. nonce="library:99",
  1837. expires_at=now + timedelta(minutes=5),
  1838. )
  1839. )
  1840. await db_session.commit()
  1841. first = await verify_slicer_download_token(token_val, "library", 99)
  1842. assert first is True
  1843. second = await verify_slicer_download_token(token_val, "library", 99)
  1844. assert second is False
  1845. # ===========================================================================
  1846. # M-NEW-3 / L-NEW-1: Schema length validation for change-password & forgot-password
  1847. # ===========================================================================
  1848. class TestSchemaLengthValidationR2:
  1849. """Input length limits added in review round 2."""
  1850. @pytest.mark.asyncio
  1851. @pytest.mark.integration
  1852. async def test_change_password_current_too_long_rejected(self, async_client: AsyncClient):
  1853. """current_password > 256 chars must be rejected with 422 (prevents pbkdf2 DoS)."""
  1854. resp = await async_client.post(
  1855. "/api/v1/users/me/change-password",
  1856. json={"current_password": "x" * 257, "new_password": "ValidPass1!"},
  1857. )
  1858. assert resp.status_code == 422
  1859. @pytest.mark.asyncio
  1860. @pytest.mark.integration
  1861. async def test_forgot_password_email_too_long_rejected(self, async_client: AsyncClient):
  1862. """email > 254 chars must be rejected with 422."""
  1863. resp = await async_client.post(
  1864. "/api/v1/auth/forgot-password",
  1865. json={"email": "a" * 243 + "@example.com"},
  1866. )
  1867. assert resp.status_code == 422
  1868. @pytest.mark.asyncio
  1869. @pytest.mark.integration
  1870. async def test_forgot_password_email_at_limit_passes_schema(self, async_client: AsyncClient):
  1871. """Short email passes schema (may return 400/200 from business logic)."""
  1872. resp = await async_client.post(
  1873. "/api/v1/auth/forgot-password",
  1874. json={"email": "user@example.com"},
  1875. )
  1876. assert resp.status_code != 422
  1877. # ===========================================================================
  1878. # L-NEW-2: TOTPSetupRequest.code max_length
  1879. # ===========================================================================
  1880. class TestTOTPSetupCodeMaxLength:
  1881. """TOTPSetupRequest.code must be bounded (L-NEW-2)."""
  1882. @pytest.mark.asyncio
  1883. @pytest.mark.integration
  1884. async def test_setup_code_too_long_rejected(self, async_client: AsyncClient):
  1885. """code > 8 chars must be rejected with 422."""
  1886. import pyotp as _pyotp
  1887. token = await _setup_and_login(async_client, "totp_setup_maxlen", "totp_setup_maxlen1")
  1888. # Enable TOTP so the setup-code guard path is active
  1889. setup_resp = await async_client.post("/api/v1/auth/2fa/totp/setup", headers=_auth_header(token))
  1890. secret = setup_resp.json()["secret"]
  1891. await async_client.post(
  1892. "/api/v1/auth/2fa/totp/enable",
  1893. json={"code": _pyotp.TOTP(secret).now()},
  1894. headers=_auth_header(token),
  1895. )
  1896. resp = await async_client.post(
  1897. "/api/v1/auth/2fa/totp/setup",
  1898. json={"code": "1" * 9},
  1899. headers=_auth_header(token),
  1900. )
  1901. assert resp.status_code == 422
  1902. # ===========================================================================
  1903. # L-NEW-3: EmailOTPEnableConfirmRequest.code must be exactly 6 digits
  1904. # ===========================================================================
  1905. class TestEmailOTPConfirmCodeFormat:
  1906. """EmailOTPEnableConfirmRequest.code must be 6 digits (L-NEW-3)."""
  1907. @pytest.mark.asyncio
  1908. @pytest.mark.integration
  1909. async def test_non_digit_code_rejected(self, async_client: AsyncClient):
  1910. """Alpha characters in the email OTP confirm code must be rejected with 422."""
  1911. token = await _setup_and_login(async_client, "emailotpfmt", "emailotpfmt1")
  1912. resp = await async_client.post(
  1913. "/api/v1/auth/2fa/email/enable/confirm",
  1914. json={"setup_token": "x" * 32, "code": "ABCDEF"},
  1915. headers=_auth_header(token),
  1916. )
  1917. assert resp.status_code == 422
  1918. @pytest.mark.asyncio
  1919. @pytest.mark.integration
  1920. async def test_seven_digit_code_rejected(self, async_client: AsyncClient):
  1921. """7-digit code must be rejected with 422 (min_length=max_length=6)."""
  1922. token = await _setup_and_login(async_client, "emailotplen7", "emailotplen7x")
  1923. resp = await async_client.post(
  1924. "/api/v1/auth/2fa/email/enable/confirm",
  1925. json={"setup_token": "x" * 32, "code": "1234567"},
  1926. headers=_auth_header(token),
  1927. )
  1928. assert resp.status_code == 422
  1929. @pytest.mark.asyncio
  1930. @pytest.mark.integration
  1931. async def test_valid_six_digit_code_passes_schema(self, async_client: AsyncClient):
  1932. """6-digit numeric code passes schema (may return 400 on bad token — that's fine)."""
  1933. token = await _setup_and_login(async_client, "emailotpfmt6", "emailotpfmt6x")
  1934. resp = await async_client.post(
  1935. "/api/v1/auth/2fa/email/enable/confirm",
  1936. json={"setup_token": "x" * 32, "code": "123456"},
  1937. headers=_auth_header(token),
  1938. )
  1939. assert resp.status_code != 422
  1940. # ===========================================================================
  1941. # L-NEW-4: OIDCProviderCreate field max_length constraints
  1942. # ===========================================================================
  1943. class TestOIDCProviderFieldLengths:
  1944. """OIDCProviderCreate fields must reject inputs exceeding max_length (L-NEW-4)."""
  1945. @pytest.mark.asyncio
  1946. @pytest.mark.integration
  1947. async def test_name_too_long_rejected(self, async_client: AsyncClient):
  1948. token = await _setup_and_login(async_client, "oidcfldadmin", "oidcfldadmin1")
  1949. resp = await async_client.post(
  1950. "/api/v1/auth/oidc/providers",
  1951. json={
  1952. "name": "n" * 101,
  1953. "issuer_url": "https://test.example.com",
  1954. "client_id": "cid",
  1955. "client_secret": "csec",
  1956. "scopes": "openid",
  1957. },
  1958. headers=_auth_header(token),
  1959. )
  1960. assert resp.status_code == 422
  1961. @pytest.mark.asyncio
  1962. @pytest.mark.integration
  1963. async def test_client_secret_too_long_rejected(self, async_client: AsyncClient):
  1964. token = await _setup_and_login(async_client, "oidcseclen", "oidcseclen123")
  1965. resp = await async_client.post(
  1966. "/api/v1/auth/oidc/providers",
  1967. json={
  1968. "name": "ValidName",
  1969. "issuer_url": "https://test.example.com",
  1970. "client_id": "cid",
  1971. "client_secret": "s" * 513,
  1972. "scopes": "openid",
  1973. },
  1974. headers=_auth_header(token),
  1975. )
  1976. assert resp.status_code == 422
  1977. # ---------------------------------------------------------------------------
  1978. # M-NEW-4 / M-NEW-5 / L-NEW-5: UserCreate & UserUpdate field length limits
  1979. # ---------------------------------------------------------------------------
  1980. class TestUserCreateUpdateFieldLengths:
  1981. """UserCreate and UserUpdate must enforce max_length on username, password, email."""
  1982. @pytest.fixture
  1983. async def admin_token(self, async_client: AsyncClient) -> str:
  1984. return await _setup_and_login(async_client, "ucfldadmin", "ucfldadmin1!")
  1985. @pytest.mark.asyncio
  1986. @pytest.mark.integration
  1987. async def test_create_username_too_long_rejected(self, async_client: AsyncClient, admin_token: str):
  1988. resp = await async_client.post(
  1989. "/api/v1/users/",
  1990. json={
  1991. "username": "u" * 151,
  1992. "password": "ValidPass1!",
  1993. "role": "user",
  1994. },
  1995. headers=_auth_header(admin_token),
  1996. )
  1997. assert resp.status_code == 422
  1998. @pytest.mark.asyncio
  1999. @pytest.mark.integration
  2000. async def test_create_password_too_long_rejected(self, async_client: AsyncClient, admin_token: str):
  2001. resp = await async_client.post(
  2002. "/api/v1/users/",
  2003. json={
  2004. "username": "newuserX",
  2005. "password": "A1!" + "x" * 254,
  2006. "role": "user",
  2007. },
  2008. headers=_auth_header(admin_token),
  2009. )
  2010. assert resp.status_code == 422
  2011. @pytest.mark.asyncio
  2012. @pytest.mark.integration
  2013. async def test_create_email_too_long_rejected(self, async_client: AsyncClient, admin_token: str):
  2014. resp = await async_client.post(
  2015. "/api/v1/users/",
  2016. json={
  2017. "username": "newuserY",
  2018. "password": "ValidPass1!",
  2019. "email": "a" * 246 + "@x.com", # total 253 chars -> fine; 248+@x.com=255 -> too long
  2020. "role": "user",
  2021. },
  2022. headers=_auth_header(admin_token),
  2023. )
  2024. # 248 'a' + '@x.com' (6) = 254 chars — just at limit, should pass
  2025. # Use 249 + '@x.com' = 255 chars to trigger the 422
  2026. assert resp.status_code in (201, 422) # boundary sanity check
  2027. @pytest.mark.asyncio
  2028. @pytest.mark.integration
  2029. async def test_create_email_exceeds_limit_rejected(self, async_client: AsyncClient, admin_token: str):
  2030. resp = await async_client.post(
  2031. "/api/v1/users/",
  2032. json={
  2033. "username": "newuserZ",
  2034. "password": "ValidPass1!",
  2035. "email": "a" * 249 + "@x.com", # 255 chars — exceeds RFC 5321 max of 254
  2036. "role": "user",
  2037. },
  2038. headers=_auth_header(admin_token),
  2039. )
  2040. assert resp.status_code == 422
  2041. @pytest.mark.asyncio
  2042. @pytest.mark.integration
  2043. async def test_update_username_too_long_rejected(self, async_client: AsyncClient, admin_token: str):
  2044. # Create a user first
  2045. create_resp = await async_client.post(
  2046. "/api/v1/users/",
  2047. json={"username": "updusr1", "password": "ValidPass1!", "role": "user"},
  2048. headers=_auth_header(admin_token),
  2049. )
  2050. assert create_resp.status_code == 201
  2051. user_id = create_resp.json()["id"]
  2052. resp = await async_client.patch(
  2053. f"/api/v1/users/{user_id}",
  2054. json={"username": "u" * 151},
  2055. headers=_auth_header(admin_token),
  2056. )
  2057. assert resp.status_code == 422
  2058. @pytest.mark.asyncio
  2059. @pytest.mark.integration
  2060. async def test_update_password_too_long_rejected(self, async_client: AsyncClient, admin_token: str):
  2061. create_resp = await async_client.post(
  2062. "/api/v1/users/",
  2063. json={"username": "updusr2", "password": "ValidPass1!", "role": "user"},
  2064. headers=_auth_header(admin_token),
  2065. )
  2066. assert create_resp.status_code == 201
  2067. user_id = create_resp.json()["id"]
  2068. resp = await async_client.patch(
  2069. f"/api/v1/users/{user_id}",
  2070. json={"password": "A1!" + "x" * 254},
  2071. headers=_auth_header(admin_token),
  2072. )
  2073. assert resp.status_code == 422
  2074. @pytest.mark.asyncio
  2075. @pytest.mark.integration
  2076. async def test_update_email_too_long_rejected(self, async_client: AsyncClient, admin_token: str):
  2077. create_resp = await async_client.post(
  2078. "/api/v1/users/",
  2079. json={"username": "updusr3", "password": "ValidPass1!", "role": "user"},
  2080. headers=_auth_header(admin_token),
  2081. )
  2082. assert create_resp.status_code == 201
  2083. user_id = create_resp.json()["id"]
  2084. resp = await async_client.patch(
  2085. f"/api/v1/users/{user_id}",
  2086. json={"email": "a" * 249 + "@x.com"}, # 255 chars
  2087. headers=_auth_header(admin_token),
  2088. )
  2089. assert resp.status_code == 422
  2090. # ---------------------------------------------------------------------------
  2091. # L-NEW-6: per-IP rate limiting on /forgot-password
  2092. # ---------------------------------------------------------------------------
  2093. _SMTP_DATA_FOR_IPLIMIT = {
  2094. "smtp_host": "smtp.test.com",
  2095. "smtp_port": 587,
  2096. "smtp_username": "test@test.com",
  2097. "smtp_password": "testpass",
  2098. "smtp_security": "starttls",
  2099. "smtp_auth_enabled": True,
  2100. "smtp_from_email": "noreply@test.com",
  2101. }
  2102. class TestForgotPasswordPerIpRateLimit:
  2103. """POST /forgot-password must enforce a per-IP cap (L-NEW-6).
  2104. The test sends 11 requests from the simulated test-client IP using 11
  2105. different email addresses (so the per-email bucket is never exhausted).
  2106. The 11th request must be rejected with 429.
  2107. """
  2108. @pytest.fixture
  2109. async def advanced_auth_token(self, async_client: AsyncClient) -> str:
  2110. """Set up auth, SMTP, and enable advanced auth; return admin token."""
  2111. token = await _setup_and_login(async_client, "iprladmin", "iprladmin1!")
  2112. headers = _auth_header(token)
  2113. await async_client.post("/api/v1/auth/smtp", headers=headers, json=_SMTP_DATA_FOR_IPLIMIT)
  2114. await async_client.post("/api/v1/auth/advanced-auth/enable", headers=headers)
  2115. return token
  2116. @pytest.mark.asyncio
  2117. @pytest.mark.integration
  2118. async def test_per_ip_limit_triggers_429(self, async_client: AsyncClient, advanced_auth_token: str):
  2119. # Send 11 requests from the same test-client IP using unique email
  2120. # addresses so the per-email bucket (limit=3) is never exhausted.
  2121. responses = []
  2122. for i in range(11):
  2123. resp = await async_client.post(
  2124. "/api/v1/auth/forgot-password",
  2125. json={"email": f"unique{i}@example.com"},
  2126. )
  2127. responses.append(resp.status_code)
  2128. # First 10 must not be rate-limited by the IP bucket
  2129. for code in responses[:10]:
  2130. assert code != 429, f"Unexpected 429 before limit reached: {responses}"
  2131. # The 11th must be rate-limited
  2132. assert responses[10] == 429, f"Expected 429 on 11th request, got {responses[10]}"
  2133. # ---------------------------------------------------------------------------
  2134. # M-NEW-6: OIDC auto-link must be rejected if target user already has an
  2135. # OIDC link to a different provider
  2136. # ---------------------------------------------------------------------------
  2137. class TestOIDCAutoLinkExistingLinkRejection:
  2138. """OIDC callback must reject auto-linking when the email-matched user
  2139. already has an OIDC link to a different provider (M-NEW-6)."""
  2140. @pytest.mark.asyncio
  2141. @pytest.mark.integration
  2142. async def test_auto_link_rejected_when_user_already_linked(
  2143. self, async_client: AsyncClient, db_session: AsyncSession
  2144. ):
  2145. """Auto-link via email-match is rejected when the target user is
  2146. already linked to another OIDC provider."""
  2147. import base64
  2148. import hashlib
  2149. from unittest.mock import AsyncMock, MagicMock, patch
  2150. from backend.app.core.auth import get_password_hash
  2151. from backend.app.models.oidc_provider import OIDCProvider, UserOIDCLink
  2152. from backend.app.models.user import User
  2153. # ── 1. Target user with a known email ────────────────────────────
  2154. target = User(
  2155. username="oidcALTarget",
  2156. email="alinktest@example.com",
  2157. auth_source="oidc",
  2158. password_hash=get_password_hash(secrets.token_urlsafe(16)),
  2159. role="user",
  2160. is_active=True,
  2161. )
  2162. db_session.add(target)
  2163. await db_session.flush()
  2164. # ── 2. Provider B — legitimate, already linked to target ──────────
  2165. prov_b = OIDCProvider(
  2166. name="ProvB_m6test",
  2167. issuer_url="https://providerb-m6.example.com",
  2168. client_id="client_b",
  2169. _client_secret_enc="secret_b",
  2170. scopes="openid email profile",
  2171. is_enabled=True,
  2172. auto_link_existing_accounts=False,
  2173. auto_create_users=False,
  2174. )
  2175. db_session.add(prov_b)
  2176. await db_session.flush()
  2177. db_session.add(
  2178. UserOIDCLink(
  2179. user_id=target.id,
  2180. provider_id=prov_b.id,
  2181. provider_user_id="legitimate_sub",
  2182. provider_email="alinktest@example.com",
  2183. )
  2184. )
  2185. # ── 3. Provider A — attacker-controlled, auto_link=True ───────────
  2186. prov_a = OIDCProvider(
  2187. name="ProvA_m6test",
  2188. issuer_url="https://providera-m6.example.com",
  2189. client_id="client_a",
  2190. _client_secret_enc="secret_a",
  2191. scopes="openid email profile",
  2192. is_enabled=True,
  2193. auto_link_existing_accounts=True,
  2194. auto_create_users=False,
  2195. )
  2196. db_session.add(prov_a)
  2197. await db_session.flush()
  2198. # ── 4. OIDC state for Provider A ──────────────────────────────────
  2199. state = secrets.token_urlsafe(32)
  2200. nonce = secrets.token_urlsafe(32)
  2201. code_verifier = secrets.token_urlsafe(48)
  2202. db_session.add(
  2203. AuthEphemeralToken(
  2204. token=state,
  2205. token_type="oidc_state",
  2206. provider_id=prov_a.id,
  2207. nonce=nonce,
  2208. code_verifier=code_verifier,
  2209. expires_at=datetime.now(timezone.utc) + timedelta(minutes=10),
  2210. )
  2211. )
  2212. await db_session.commit()
  2213. # ── 5. Mock HTTP + JWT so the callback can reach the auto-link check ─
  2214. fake_discovery = {
  2215. "issuer": "https://providera-m6.example.com",
  2216. "token_endpoint": "https://providera-m6.example.com/token",
  2217. "jwks_uri": "https://providera-m6.example.com/jwks",
  2218. }
  2219. fake_token = {"access_token": "acc_tok", "id_token": "fake.id.token"}
  2220. fake_claims = {
  2221. "sub": "attacker_sub_unique",
  2222. "email": "alinktest@example.com",
  2223. "email_verified": True,
  2224. "nonce": nonce,
  2225. "iss": "https://providera-m6.example.com",
  2226. "aud": "client_a",
  2227. "exp": 9_999_999_999,
  2228. }
  2229. disc_resp = AsyncMock()
  2230. disc_resp.raise_for_status = MagicMock()
  2231. disc_resp.json = MagicMock(return_value=fake_discovery)
  2232. token_resp = AsyncMock()
  2233. token_resp.ok = True
  2234. token_resp.json = MagicMock(return_value=fake_token)
  2235. jwks_resp = AsyncMock()
  2236. jwks_resp.raise_for_status = MagicMock()
  2237. jwks_resp.json = MagicMock(return_value={})
  2238. mock_http = AsyncMock()
  2239. mock_http.get = AsyncMock(side_effect=[disc_resp, jwks_resp])
  2240. mock_http.post = AsyncMock(return_value=token_resp)
  2241. mock_signing_key = MagicMock()
  2242. mock_signing_key.key = "fake_key"
  2243. with (
  2244. patch("backend.app.api.routes.mfa.httpx.AsyncClient") as mock_httpx_cls,
  2245. patch("backend.app.api.routes.mfa.jwt.decode", return_value=fake_claims),
  2246. patch("backend.app.api.routes.mfa.PyJWKClient") as mock_jwks_cls,
  2247. ):
  2248. mock_httpx_cls.return_value.__aenter__ = AsyncMock(return_value=mock_http)
  2249. mock_httpx_cls.return_value.__aexit__ = AsyncMock(return_value=False)
  2250. mock_jwks_cls.return_value.get_signing_key_from_jwt.return_value = mock_signing_key
  2251. resp = await async_client.get(
  2252. f"/api/v1/auth/oidc/callback?code=fake_code&state={state}",
  2253. follow_redirects=False,
  2254. )
  2255. # M-NEW-6: must redirect with no_linked_account — NOT create a second link
  2256. assert resp.status_code == 302
  2257. location = resp.headers.get("location", "")
  2258. assert "no_linked_account" in location, f"Expected no_linked_account in redirect, got: {location}"
  2259. # Verify no second OIDC link was created for Provider A
  2260. from sqlalchemy import select as sa_select
  2261. from backend.app.models.oidc_provider import UserOIDCLink as _UOL
  2262. async with db_session as s:
  2263. links_result = await s.execute(
  2264. sa_select(_UOL).where(_UOL.user_id == target.id, _UOL.provider_id == prov_a.id)
  2265. )
  2266. assert links_result.scalar_one_or_none() is None, "No link to Provider A must exist"
  2267. # ===========================================================================
  2268. # Test Gap 1: OIDC state token is single-use — replay must be rejected
  2269. # ===========================================================================
  2270. class TestOIDCStateReplay:
  2271. """OIDC state token must be consumed on first use; a second callback with
  2272. the same state must redirect to ``?oidc_error=invalid_state``."""
  2273. @pytest.mark.asyncio
  2274. @pytest.mark.integration
  2275. async def test_state_replay_rejected(self, async_client: AsyncClient, db_session: AsyncSession):
  2276. """Replaying a consumed OIDC state token must return invalid_state."""
  2277. from backend.app.models.oidc_provider import OIDCProvider
  2278. # ── 1. Seed a minimal provider ────────────────────────────────────
  2279. provider = OIDCProvider(
  2280. name="StateReplayIdP",
  2281. issuer_url="https://statereplay-idp.example.com",
  2282. client_id="client_replay",
  2283. _client_secret_enc="secret_replay",
  2284. scopes="openid",
  2285. is_enabled=True,
  2286. auto_link_existing_accounts=False,
  2287. auto_create_users=False,
  2288. )
  2289. db_session.add(provider)
  2290. await db_session.flush()
  2291. # ── 2. Seed an OIDC state token ───────────────────────────────────
  2292. state = secrets.token_urlsafe(32)
  2293. db_session.add(
  2294. AuthEphemeralToken(
  2295. token=state,
  2296. token_type="oidc_state",
  2297. provider_id=provider.id,
  2298. nonce=secrets.token_urlsafe(32),
  2299. code_verifier=secrets.token_urlsafe(48),
  2300. expires_at=datetime.now(timezone.utc) + timedelta(minutes=10),
  2301. )
  2302. )
  2303. await db_session.commit()
  2304. # ── 3. First callback — discovery will fail (no real IdP), but the
  2305. # state token is atomically consumed (DELETE…RETURNING + commit)
  2306. # before the HTTP call is attempted.
  2307. first = await async_client.get(
  2308. f"/api/v1/auth/oidc/callback?code=any_code&state={state}",
  2309. follow_redirects=False,
  2310. )
  2311. assert first.status_code == 302
  2312. # The first call may fail for any reason except invalid_state
  2313. assert "invalid_state" not in first.headers.get("location", ""), (
  2314. f"First call should NOT get invalid_state: {first.headers.get('location')}"
  2315. )
  2316. # ── 4. Second callback with the same state → must be invalid_state ─
  2317. second = await async_client.get(
  2318. f"/api/v1/auth/oidc/callback?code=any_code&state={state}",
  2319. follow_redirects=False,
  2320. )
  2321. assert second.status_code == 302
  2322. assert "invalid_state" in second.headers.get("location", ""), (
  2323. f"Replayed state must redirect to invalid_state, got: {second.headers.get('location')}"
  2324. )
  2325. # ===========================================================================
  2326. # Test Gap 2: OIDC iss claim mismatch must redirect to token_validation_failed
  2327. # ===========================================================================
  2328. class TestOIDCIssMismatch:
  2329. """JWT whose iss claim does not match the discovery issuer must be rejected."""
  2330. @pytest.mark.asyncio
  2331. @pytest.mark.integration
  2332. async def test_iss_mismatch_redirects_token_validation_failed(
  2333. self, async_client: AsyncClient, db_session: AsyncSession
  2334. ):
  2335. import time
  2336. from unittest.mock import patch
  2337. import jwt as pyjwt
  2338. private_pem, jwks_data = _make_test_rsa_key()
  2339. correct_issuer = "https://correct-iss.example.com"
  2340. wrong_issuer = "https://wrong-iss.example.com"
  2341. client_id = "iss-mismatch-client"
  2342. nonce = secrets.token_urlsafe(16)
  2343. now = int(time.time())
  2344. # Sign the token with the WRONG issuer (iss != discovery_issuer)
  2345. id_token = pyjwt.encode(
  2346. {
  2347. "sub": "sub-iss-test",
  2348. "iss": wrong_issuer,
  2349. "aud": client_id,
  2350. "nonce": nonce,
  2351. "email": "iss@example.com",
  2352. "email_verified": True,
  2353. "iat": now,
  2354. "exp": now + 300,
  2355. },
  2356. private_pem,
  2357. algorithm="RS256",
  2358. headers={"kid": "test-kid-1"},
  2359. )
  2360. admin_token = await _setup_and_login(async_client, "issadmin1", "issadmin1!")
  2361. cr = await async_client.post(
  2362. "/api/v1/auth/oidc/providers",
  2363. json={
  2364. "name": "IssTest-IdP",
  2365. "issuer_url": correct_issuer,
  2366. "client_id": client_id,
  2367. "client_secret": "s",
  2368. "scopes": "openid",
  2369. "is_enabled": True,
  2370. "auto_create_users": True,
  2371. },
  2372. headers=_auth_header(admin_token),
  2373. )
  2374. assert cr.status_code in (200, 201), cr.text
  2375. provider_id = cr.json()["id"]
  2376. state = secrets.token_urlsafe(32)
  2377. db_session.add(
  2378. AuthEphemeralToken(
  2379. token=state,
  2380. token_type="oidc_state",
  2381. provider_id=provider_id,
  2382. nonce=nonce,
  2383. code_verifier=secrets.token_urlsafe(48),
  2384. expires_at=datetime.now(timezone.utc) + timedelta(minutes=5),
  2385. )
  2386. )
  2387. await db_session.commit()
  2388. # Discovery returns the CORRECT issuer; JWT carries the WRONG one.
  2389. discovery_doc = {
  2390. "issuer": correct_issuer,
  2391. "token_endpoint": f"{correct_issuer}/token",
  2392. "jwks_uri": f"{correct_issuer}/.well-known/jwks.json",
  2393. }
  2394. token_response = {"access_token": "a", "id_token": id_token}
  2395. class _MockResp:
  2396. def __init__(self, data):
  2397. self._data = data
  2398. self.status_code = 200
  2399. self.is_success = True
  2400. self.text = ""
  2401. def json(self):
  2402. return self._data
  2403. def raise_for_status(self):
  2404. pass
  2405. class _MockHttpxClient:
  2406. def __init__(self, *a, **kw):
  2407. pass
  2408. async def __aenter__(self):
  2409. return self
  2410. async def __aexit__(self, *a):
  2411. pass
  2412. async def get(self, url, **kw):
  2413. return _MockResp(jwks_data if "jwks" in url else discovery_doc)
  2414. async def post(self, url, **kw):
  2415. return _MockResp(token_response)
  2416. with patch("backend.app.api.routes.mfa.httpx.AsyncClient", _MockHttpxClient):
  2417. resp = await async_client.get(
  2418. f"/api/v1/auth/oidc/callback?code=c&state={state}",
  2419. follow_redirects=False,
  2420. )
  2421. assert resp.status_code == 302
  2422. location = resp.headers.get("location", "")
  2423. assert "token_validation_failed" in location, f"Expected token_validation_failed, got: {location}"
  2424. # ===========================================================================
  2425. # Test Gap 3: /forgot-password/confirm token is single-use
  2426. # ===========================================================================
  2427. class TestForgotPasswordTokenSingleUse:
  2428. """POST /forgot-password/confirm must reject a token after its first use."""
  2429. @pytest.mark.asyncio
  2430. @pytest.mark.integration
  2431. async def test_token_reuse_rejected(self, async_client: AsyncClient, db_session: AsyncSession):
  2432. from backend.app.core.auth import get_password_hash
  2433. from backend.app.models.user import User as _User
  2434. user = _User(
  2435. username="fpcuser1",
  2436. email="fpc@example.com",
  2437. password_hash=get_password_hash("OldPass1!"),
  2438. role="user",
  2439. is_active=True,
  2440. )
  2441. db_session.add(user)
  2442. await db_session.flush()
  2443. reset_token = secrets.token_urlsafe(32)
  2444. db_session.add(
  2445. AuthEphemeralToken(
  2446. token=reset_token,
  2447. token_type="password_reset",
  2448. username="fpcuser1",
  2449. expires_at=datetime.now(timezone.utc) + timedelta(hours=1),
  2450. )
  2451. )
  2452. await db_session.commit()
  2453. # First use → success
  2454. resp1 = await async_client.post(
  2455. "/api/v1/auth/forgot-password/confirm",
  2456. json={"token": reset_token, "new_password": "NewPass1!"},
  2457. )
  2458. assert resp1.status_code == 200, resp1.text
  2459. # Second use → token already consumed, must fail
  2460. resp2 = await async_client.post(
  2461. "/api/v1/auth/forgot-password/confirm",
  2462. json={"token": reset_token, "new_password": "AnotherNew1!"},
  2463. )
  2464. assert resp2.status_code == 400
  2465. # ===========================================================================
  2466. # C1 regression: setup_totp must reject a replayed TOTP code
  2467. # ===========================================================================
  2468. class TestSetupTOTPReplayRejected:
  2469. """setup_totp must reject a TOTP code that was already accepted in its window."""
  2470. @pytest.mark.asyncio
  2471. @pytest.mark.integration
  2472. async def test_replayed_setup_code_rejected(self, async_client: AsyncClient, db_session: AsyncSession):
  2473. from sqlalchemy import select as sa_select
  2474. from backend.app.models.user_totp import UserTOTP
  2475. token = await _setup_and_login(async_client, "setupreplay1", "setupreplay1!")
  2476. # Step 1: Initial TOTP setup (no active TOTP yet → no code required)
  2477. setup_resp = await async_client.post(
  2478. "/api/v1/auth/2fa/totp/setup",
  2479. headers=_auth_header(token),
  2480. )
  2481. assert setup_resp.status_code == 200
  2482. secret = setup_resp.json()["secret"]
  2483. # Step 2: Enable TOTP with a valid code
  2484. totp_obj = pyotp.TOTP(secret)
  2485. enable_resp = await async_client.post(
  2486. "/api/v1/auth/2fa/totp/enable",
  2487. json={"code": totp_obj.now()},
  2488. headers=_auth_header(token),
  2489. )
  2490. assert enable_resp.status_code == 200 # TOTP is now active (is_enabled=True)
  2491. # Step 3: Determine current valid code and its counter
  2492. me_resp = await async_client.get("/api/v1/auth/me", headers=_auth_header(token))
  2493. user_id = me_resp.json()["id"]
  2494. totp_result = await db_session.execute(sa_select(UserTOTP).where(UserTOTP.user_id == user_id))
  2495. totp_record = totp_result.scalar_one()
  2496. secret_now = totp_record.secret # decrypted via property
  2497. totp_now = pyotp.TOTP(secret_now)
  2498. valid_code = totp_now.now()
  2499. accepted_counter = totp_now.timecode(datetime.now(timezone.utc))
  2500. # Step 4: Pre-set last_totp_counter so this code looks already used
  2501. totp_record.last_totp_counter = accepted_counter
  2502. await db_session.commit()
  2503. # Step 5: Attempt setup_totp with the "already used" code → must be rejected
  2504. replay_resp = await async_client.post(
  2505. "/api/v1/auth/2fa/totp/setup",
  2506. json={"code": valid_code},
  2507. headers=_auth_header(token),
  2508. )
  2509. assert replay_resp.status_code == 400
  2510. assert "already used" in replay_resp.json()["detail"]
  2511. # ===========================================================================
  2512. # Nit8: OIDC aud mismatch and nonce mismatch tests
  2513. # ===========================================================================
  2514. class TestOIDCAudAndNonceMismatch:
  2515. """Nit8: aud != client_id and nonce != stored value must each fail the callback."""
  2516. def _make_oidc_provider_setup(self):
  2517. """Return a helper for building OIDC test fixtures inline."""
  2518. private_pem, jwks_data = _make_test_rsa_key()
  2519. return private_pem, jwks_data
  2520. @pytest.mark.asyncio
  2521. @pytest.mark.integration
  2522. async def test_aud_mismatch_redirects_token_validation_failed(
  2523. self, async_client: AsyncClient, db_session: AsyncSession
  2524. ):
  2525. """ID token with aud != client_id must be rejected (PyJWT InvalidAudienceError)."""
  2526. import time
  2527. from unittest.mock import patch
  2528. import jwt as pyjwt
  2529. private_pem, jwks_data = _make_test_rsa_key()
  2530. issuer = "https://aud-mismatch.example.com"
  2531. client_id = "aud-test-client"
  2532. wrong_aud = "some-other-client"
  2533. nonce = secrets.token_urlsafe(16)
  2534. now = int(time.time())
  2535. id_token = pyjwt.encode(
  2536. {
  2537. "sub": "sub-aud-test",
  2538. "iss": issuer,
  2539. "aud": wrong_aud, # <-- wrong audience
  2540. "nonce": nonce,
  2541. "email": "aud@example.com",
  2542. "email_verified": True,
  2543. "iat": now,
  2544. "exp": now + 300,
  2545. },
  2546. private_pem,
  2547. algorithm="RS256",
  2548. headers={"kid": "test-kid-1"},
  2549. )
  2550. admin_token = await _setup_and_login(async_client, "audmismatch_admin", "AudMismatch_admin1")
  2551. cr = await async_client.post(
  2552. "/api/v1/auth/oidc/providers",
  2553. json={
  2554. "name": "AudMismatch-IdP",
  2555. "issuer_url": issuer,
  2556. "client_id": client_id,
  2557. "client_secret": "s",
  2558. "scopes": "openid",
  2559. "is_enabled": True,
  2560. "auto_create_users": True,
  2561. },
  2562. headers=_auth_header(admin_token),
  2563. )
  2564. assert cr.status_code in (200, 201), cr.text
  2565. provider_id = cr.json()["id"]
  2566. state = secrets.token_urlsafe(32)
  2567. db_session.add(
  2568. AuthEphemeralToken(
  2569. token=state,
  2570. token_type="oidc_state",
  2571. provider_id=provider_id,
  2572. nonce=nonce,
  2573. code_verifier=secrets.token_urlsafe(48),
  2574. expires_at=datetime.now(timezone.utc) + timedelta(minutes=5),
  2575. )
  2576. )
  2577. await db_session.commit()
  2578. discovery_doc = {
  2579. "issuer": issuer,
  2580. "token_endpoint": f"{issuer}/token",
  2581. "jwks_uri": f"{issuer}/.well-known/jwks.json",
  2582. }
  2583. class _MockResp:
  2584. def __init__(self, data):
  2585. self._data = data
  2586. self.status_code = 200
  2587. self.is_success = True
  2588. self.text = ""
  2589. def json(self):
  2590. return self._data
  2591. def raise_for_status(self):
  2592. pass
  2593. class _MockHttpxClient:
  2594. def __init__(self, *a, **kw):
  2595. pass
  2596. async def __aenter__(self):
  2597. return self
  2598. async def __aexit__(self, *a):
  2599. pass
  2600. async def get(self, url, **kw):
  2601. return _MockResp(jwks_data if "jwks" in url else discovery_doc)
  2602. async def post(self, url, **kw):
  2603. return _MockResp({"access_token": "a", "id_token": id_token})
  2604. with patch("backend.app.api.routes.mfa.httpx.AsyncClient", _MockHttpxClient):
  2605. resp = await async_client.get(
  2606. f"/api/v1/auth/oidc/callback?code=c&state={state}",
  2607. follow_redirects=False,
  2608. )
  2609. assert resp.status_code == 302
  2610. location = resp.headers.get("location", "")
  2611. assert "token_validation_failed" in location, (
  2612. f"Expected token_validation_failed redirect for aud mismatch, got: {location}"
  2613. )
  2614. @pytest.mark.asyncio
  2615. @pytest.mark.integration
  2616. async def test_nonce_mismatch_redirects_token_validation_failed(
  2617. self, async_client: AsyncClient, db_session: AsyncSession
  2618. ):
  2619. """ID token with nonce != stored state nonce must be rejected."""
  2620. import time
  2621. from unittest.mock import patch
  2622. import jwt as pyjwt
  2623. private_pem, jwks_data = _make_test_rsa_key()
  2624. issuer = "https://nonce-mismatch.example.com"
  2625. client_id = "nonce-test-client"
  2626. stored_nonce = secrets.token_urlsafe(16)
  2627. wrong_nonce = secrets.token_urlsafe(16) # different from stored_nonce
  2628. now = int(time.time())
  2629. id_token = pyjwt.encode(
  2630. {
  2631. "sub": "sub-nonce-test",
  2632. "iss": issuer,
  2633. "aud": client_id,
  2634. "nonce": wrong_nonce, # <-- does not match stored_nonce
  2635. "email": "nonce@example.com",
  2636. "email_verified": True,
  2637. "iat": now,
  2638. "exp": now + 300,
  2639. },
  2640. private_pem,
  2641. algorithm="RS256",
  2642. headers={"kid": "test-kid-1"},
  2643. )
  2644. admin_token = await _setup_and_login(async_client, "noncemismatch_admin", "NonceMismatch_admin1")
  2645. cr = await async_client.post(
  2646. "/api/v1/auth/oidc/providers",
  2647. json={
  2648. "name": "NonceMismatch-IdP",
  2649. "issuer_url": issuer,
  2650. "client_id": client_id,
  2651. "client_secret": "s",
  2652. "scopes": "openid",
  2653. "is_enabled": True,
  2654. "auto_create_users": True,
  2655. },
  2656. headers=_auth_header(admin_token),
  2657. )
  2658. assert cr.status_code in (200, 201), cr.text
  2659. provider_id = cr.json()["id"]
  2660. state = secrets.token_urlsafe(32)
  2661. db_session.add(
  2662. AuthEphemeralToken(
  2663. token=state,
  2664. token_type="oidc_state",
  2665. provider_id=provider_id,
  2666. nonce=stored_nonce, # state has correct nonce; JWT carries wrong_nonce
  2667. code_verifier=secrets.token_urlsafe(48),
  2668. expires_at=datetime.now(timezone.utc) + timedelta(minutes=5),
  2669. )
  2670. )
  2671. await db_session.commit()
  2672. discovery_doc = {
  2673. "issuer": issuer,
  2674. "token_endpoint": f"{issuer}/token",
  2675. "jwks_uri": f"{issuer}/.well-known/jwks.json",
  2676. }
  2677. class _MockResp:
  2678. def __init__(self, data):
  2679. self._data = data
  2680. self.status_code = 200
  2681. self.is_success = True
  2682. self.text = ""
  2683. def json(self):
  2684. return self._data
  2685. def raise_for_status(self):
  2686. pass
  2687. class _MockHttpxClient:
  2688. def __init__(self, *a, **kw):
  2689. pass
  2690. async def __aenter__(self):
  2691. return self
  2692. async def __aexit__(self, *a):
  2693. pass
  2694. async def get(self, url, **kw):
  2695. return _MockResp(jwks_data if "jwks" in url else discovery_doc)
  2696. async def post(self, url, **kw):
  2697. return _MockResp({"access_token": "a", "id_token": id_token})
  2698. with patch("backend.app.api.routes.mfa.httpx.AsyncClient", _MockHttpxClient):
  2699. resp = await async_client.get(
  2700. f"/api/v1/auth/oidc/callback?code=c&state={state}",
  2701. follow_redirects=False,
  2702. )
  2703. assert resp.status_code == 302
  2704. location = resp.headers.get("location", "")
  2705. # The callback redirects to ?oidc_error=nonce_mismatch when nonces differ.
  2706. assert "nonce_mismatch" in location, f"Expected nonce_mismatch redirect for nonce mismatch, got: {location}"
  2707. # ===========================================================================
  2708. # Expired OIDC token rejection — state and exchange tokens
  2709. # ===========================================================================
  2710. class TestOIDCExpiredTokenRejection:
  2711. """Expired OIDC state and exchange tokens must be rejected atomically.
  2712. The DELETE … WHERE expires_at > now must ensure that an already-expired
  2713. token is never consumed (committed) before the expiry is checked, so the
  2714. token row stays in the DB and is not silently discarded.
  2715. """
  2716. @pytest.mark.asyncio
  2717. @pytest.mark.integration
  2718. async def test_expired_state_token_rejected_as_invalid_state(
  2719. self, async_client: AsyncClient, db_session: AsyncSession
  2720. ):
  2721. """An expired OIDC state token must redirect to invalid_state without
  2722. being consumed — it must still exist in the DB after the rejected call."""
  2723. from backend.app.models.oidc_provider import OIDCProvider
  2724. provider = OIDCProvider(
  2725. name="ExpiredStateIdP",
  2726. issuer_url="https://expired-state.example.com",
  2727. client_id="client_expired_state",
  2728. _client_secret_enc="secret_exp_state",
  2729. scopes="openid",
  2730. is_enabled=True,
  2731. auto_link_existing_accounts=False,
  2732. auto_create_users=False,
  2733. )
  2734. db_session.add(provider)
  2735. await db_session.flush()
  2736. state = secrets.token_urlsafe(32)
  2737. db_session.add(
  2738. AuthEphemeralToken(
  2739. token=state,
  2740. token_type="oidc_state",
  2741. provider_id=provider.id,
  2742. nonce=secrets.token_urlsafe(16),
  2743. code_verifier=secrets.token_urlsafe(48),
  2744. # already expired
  2745. expires_at=datetime.now(timezone.utc) - timedelta(minutes=5),
  2746. )
  2747. )
  2748. await db_session.commit()
  2749. resp = await async_client.get(
  2750. f"/api/v1/auth/oidc/callback?code=any_code&state={state}",
  2751. follow_redirects=False,
  2752. )
  2753. assert resp.status_code == 302
  2754. location = resp.headers.get("location", "")
  2755. assert "invalid_state" in location, f"Expected invalid_state redirect for expired state, got: {location}"
  2756. @pytest.mark.asyncio
  2757. @pytest.mark.integration
  2758. async def test_expired_exchange_token_rejected(self, async_client: AsyncClient, db_session: AsyncSession):
  2759. """An expired OIDC exchange token must return 401 without being consumed."""
  2760. from sqlalchemy import select as sa_select
  2761. expired_token = secrets.token_urlsafe(32)
  2762. db_session.add(
  2763. AuthEphemeralToken(
  2764. token=expired_token,
  2765. token_type="oidc_exchange",
  2766. username="some_user",
  2767. # already expired
  2768. expires_at=datetime.now(timezone.utc) - timedelta(minutes=5),
  2769. )
  2770. )
  2771. await db_session.commit()
  2772. resp = await async_client.post(
  2773. "/api/v1/auth/oidc/exchange",
  2774. json={"oidc_token": expired_token},
  2775. )
  2776. assert resp.status_code == 401
  2777. assert "expired" in resp.json().get("detail", "").lower() or "invalid" in resp.json().get("detail", "").lower()
  2778. # Token must NOT have been consumed — it should still be in the DB
  2779. # (the atomic DELETE WHERE expires_at > now left it untouched)
  2780. result = await db_session.execute(
  2781. sa_select(AuthEphemeralToken).where(AuthEphemeralToken.token == expired_token)
  2782. )
  2783. remaining = result.scalar_one_or_none()
  2784. assert remaining is not None, "Expired exchange token must not be consumed by a rejected request"
  2785. # ===========================================================================
  2786. # Trailing slash in issuer_url — discovery URL must not contain double slash
  2787. # ===========================================================================
  2788. class TestOIDCIssuerUrlTrailingSlash:
  2789. """Providers like Authentik use issuer URLs with a trailing slash.
  2790. BamBuddy must strip the slash before appending /.well-known/openid-configuration
  2791. to avoid a double-slash that results in a 404.
  2792. """
  2793. @pytest.mark.asyncio
  2794. @pytest.mark.integration
  2795. async def test_trailing_slash_issuer_url_fetches_correct_discovery_url(self, async_client: AsyncClient):
  2796. from unittest.mock import AsyncMock, MagicMock, patch
  2797. issuer_with_slash = "https://authentik.example.com/application/o/bambuddy/"
  2798. admin_token = await _setup_and_login(async_client, "oidcslashadm", "oidcslashadm1")
  2799. create_resp = await async_client.post(
  2800. "/api/v1/auth/oidc/providers",
  2801. json={
  2802. "name": "Authentik-Slash",
  2803. "issuer_url": issuer_with_slash,
  2804. "client_id": "bambuddy",
  2805. "client_secret": "secret",
  2806. "scopes": "openid email profile",
  2807. "is_enabled": True,
  2808. "auto_create_users": False,
  2809. },
  2810. headers=_auth_header(admin_token),
  2811. )
  2812. assert create_resp.status_code == 201
  2813. provider_id = create_resp.json()["id"]
  2814. fake_discovery = {
  2815. "issuer": issuer_with_slash,
  2816. "authorization_endpoint": "https://authentik.example.com/application/o/bambuddy/authorize",
  2817. }
  2818. disc_resp = AsyncMock()
  2819. disc_resp.raise_for_status = MagicMock()
  2820. disc_resp.json = MagicMock(return_value=fake_discovery)
  2821. mock_http = AsyncMock()
  2822. mock_http.get = AsyncMock(return_value=disc_resp)
  2823. with patch("backend.app.api.routes.mfa.httpx.AsyncClient") as mock_cls:
  2824. mock_cls.return_value.__aenter__ = AsyncMock(return_value=mock_http)
  2825. mock_cls.return_value.__aexit__ = AsyncMock(return_value=False)
  2826. resp = await async_client.get(f"/api/v1/auth/oidc/authorize/{provider_id}")
  2827. assert resp.status_code == 200
  2828. called_url = mock_http.get.call_args_list[0][0][0]
  2829. assert "//" not in called_url.replace("https://", ""), (
  2830. f"Discovery URL must not contain double slash: {called_url}"
  2831. )
  2832. assert called_url.endswith("/.well-known/openid-configuration"), (
  2833. f"Expected discovery URL to end with /.well-known/openid-configuration, got: {called_url}"
  2834. )
  2835. @pytest.mark.asyncio
  2836. @pytest.mark.integration
  2837. async def test_iss_claim_trailing_slash_accepted(self, async_client: AsyncClient, db_session: AsyncSession):
  2838. """Provider configured without trailing slash, Authentik JWT iss has trailing slash.
  2839. Both sides must be normalised before comparison so the login succeeds.
  2840. """
  2841. import time
  2842. from unittest.mock import patch
  2843. import jwt as pyjwt
  2844. private_pem, jwks_data = _make_test_rsa_key()
  2845. issuer_no_slash = "https://authentik.example.com/application/o/bambuddy"
  2846. issuer_with_slash = issuer_no_slash + "/"
  2847. client_id = "bambuddy-client"
  2848. nonce = secrets.token_urlsafe(16)
  2849. now = int(time.time())
  2850. id_token = pyjwt.encode(
  2851. {
  2852. "sub": "authentik-sub-123",
  2853. "iss": issuer_with_slash,
  2854. "aud": client_id,
  2855. "nonce": nonce,
  2856. "email": "authentik-user@example.com",
  2857. "email_verified": True,
  2858. "iat": now,
  2859. "exp": now + 300,
  2860. },
  2861. private_pem,
  2862. algorithm="RS256",
  2863. headers={"kid": "test-kid-1"},
  2864. )
  2865. admin_token = await _setup_and_login(async_client, "authentikadm", "authentikadm1")
  2866. create_resp = await async_client.post(
  2867. "/api/v1/auth/oidc/providers",
  2868. json={
  2869. "name": "Authentik-ISS",
  2870. "issuer_url": issuer_no_slash,
  2871. "client_id": client_id,
  2872. "client_secret": "secret",
  2873. "scopes": "openid email profile",
  2874. "is_enabled": True,
  2875. "auto_create_users": True,
  2876. },
  2877. headers=_auth_header(admin_token),
  2878. )
  2879. assert create_resp.status_code == 201
  2880. provider_id = create_resp.json()["id"]
  2881. state = secrets.token_urlsafe(32)
  2882. code_verifier = secrets.token_urlsafe(48)
  2883. db_session.add(
  2884. AuthEphemeralToken(
  2885. token=state,
  2886. token_type="oidc_state",
  2887. provider_id=provider_id,
  2888. nonce=nonce,
  2889. code_verifier=code_verifier,
  2890. expires_at=datetime.now(timezone.utc) + timedelta(minutes=5),
  2891. )
  2892. )
  2893. await db_session.commit()
  2894. discovery_doc = {
  2895. "issuer": issuer_with_slash,
  2896. "authorization_endpoint": f"{issuer_no_slash}/authorize",
  2897. "token_endpoint": f"{issuer_no_slash}/token",
  2898. "jwks_uri": f"{issuer_no_slash}/.well-known/jwks.json",
  2899. }
  2900. token_response = {"access_token": "mock", "token_type": "Bearer", "id_token": id_token}
  2901. class _MockResp:
  2902. def __init__(self, data):
  2903. self._data = data
  2904. self.is_success = True
  2905. self.status_code = 200
  2906. self.text = str(data)
  2907. def json(self):
  2908. return self._data
  2909. def raise_for_status(self):
  2910. pass
  2911. class _MockHttpxClient:
  2912. def __init__(self, *a, **kw):
  2913. pass
  2914. async def __aenter__(self):
  2915. return self
  2916. async def __aexit__(self, *a):
  2917. pass
  2918. async def get(self, url, **kw):
  2919. return _MockResp(jwks_data if "jwks" in url else discovery_doc)
  2920. async def post(self, url, **kw):
  2921. return _MockResp(token_response)
  2922. with patch("backend.app.api.routes.mfa.httpx.AsyncClient", _MockHttpxClient):
  2923. resp = await async_client.get(
  2924. f"/api/v1/auth/oidc/callback?code=auth-code&state={state}",
  2925. follow_redirects=False,
  2926. )
  2927. location = resp.headers.get("location", "")
  2928. assert resp.status_code == 302, f"Expected redirect, got {resp.status_code}"
  2929. assert "token_validation_failed" not in location, (
  2930. "Trailing slash mismatch in iss claim must not cause token_validation_failed"
  2931. )
  2932. assert "oidc_token=" in location, f"Expected oidc_token in redirect, got: {location}"
  2933. class TestOIDCCallbackCodeLength:
  2934. """OIDC callback code/state query params must accept up to 2048 characters (OAuth spec)."""
  2935. @pytest.mark.asyncio
  2936. @pytest.mark.integration
  2937. async def test_code_512_chars_accepted(self, async_client: AsyncClient):
  2938. """A 512-character code (old limit) must not be rejected with 422."""
  2939. code = "a" * 512
  2940. resp = await async_client.get(
  2941. f"/api/v1/auth/oidc/callback?code={code}&state=bogus-state",
  2942. follow_redirects=False,
  2943. )
  2944. assert resp.status_code != 422, "512-char code must not be rejected by Pydantic"
  2945. @pytest.mark.asyncio
  2946. @pytest.mark.integration
  2947. async def test_code_2048_chars_accepted(self, async_client: AsyncClient):
  2948. """A 2048-character code must not be rejected with 422."""
  2949. code = "a" * 2048
  2950. resp = await async_client.get(
  2951. f"/api/v1/auth/oidc/callback?code={code}&state=bogus-state",
  2952. follow_redirects=False,
  2953. )
  2954. assert resp.status_code != 422, "2048-char code must not be rejected by Pydantic"
  2955. @pytest.mark.asyncio
  2956. @pytest.mark.integration
  2957. async def test_code_2049_chars_rejected(self, async_client: AsyncClient):
  2958. """A 2049-character code must be rejected with 422."""
  2959. code = "a" * 2049
  2960. resp = await async_client.get(
  2961. f"/api/v1/auth/oidc/callback?code={code}&state=bogus-state",
  2962. follow_redirects=False,
  2963. )
  2964. assert resp.status_code == 422, "2049-char code must be rejected by Pydantic"
  2965. @pytest.mark.asyncio
  2966. @pytest.mark.integration
  2967. async def test_state_512_chars_accepted(self, async_client: AsyncClient):
  2968. """A 512-character state (old limit) must not be rejected with 422."""
  2969. state = "a" * 512
  2970. resp = await async_client.get(
  2971. f"/api/v1/auth/oidc/callback?code=bogus-code&state={state}",
  2972. follow_redirects=False,
  2973. )
  2974. assert resp.status_code != 422, "512-char state must not be rejected by Pydantic"
  2975. @pytest.mark.asyncio
  2976. @pytest.mark.integration
  2977. async def test_state_2048_chars_accepted(self, async_client: AsyncClient):
  2978. """A 2048-character state must not be rejected with 422."""
  2979. state = "a" * 2048
  2980. resp = await async_client.get(
  2981. f"/api/v1/auth/oidc/callback?code=bogus-code&state={state}",
  2982. follow_redirects=False,
  2983. )
  2984. assert resp.status_code != 422, "2048-char state must not be rejected by Pydantic"
  2985. @pytest.mark.asyncio
  2986. @pytest.mark.integration
  2987. async def test_state_2049_chars_rejected(self, async_client: AsyncClient):
  2988. """A 2049-character state must be rejected with 422."""
  2989. state = "a" * 2049
  2990. resp = await async_client.get(
  2991. f"/api/v1/auth/oidc/callback?code=bogus-code&state={state}",
  2992. follow_redirects=False,
  2993. )
  2994. assert resp.status_code == 422, "2049-char state must be rejected by Pydantic"
  2995. # ---------------------------------------------------------------------------
  2996. # Helpers shared by TestOIDCEmailClaimResolution
  2997. # ---------------------------------------------------------------------------
  2998. async def _run_oidc_callback(
  2999. async_client: AsyncClient,
  3000. db_session: AsyncSession,
  3001. *,
  3002. provider_id: int,
  3003. claims: dict,
  3004. private_pem: bytes,
  3005. jwks_data: dict,
  3006. issuer: str,
  3007. client_id: str,
  3008. ) -> str:
  3009. """Run a full OIDC callback flow and return the redirect location."""
  3010. nonce = secrets.token_urlsafe(16)
  3011. now = int(time.time())
  3012. token_claims = {
  3013. "sub": claims.get("sub", f"sub-{secrets.token_hex(8)}"),
  3014. "iss": issuer,
  3015. "aud": client_id,
  3016. "nonce": nonce,
  3017. "iat": now,
  3018. "exp": now + 300,
  3019. **{k: v for k, v in claims.items() if k not in ("sub",)},
  3020. }
  3021. id_token = pyjwt.encode(token_claims, private_pem, algorithm="RS256", headers={"kid": "test-kid-1"})
  3022. state = secrets.token_urlsafe(32)
  3023. code_verifier = secrets.token_urlsafe(48)
  3024. db_session.add(
  3025. AuthEphemeralToken(
  3026. token=state,
  3027. token_type="oidc_state",
  3028. provider_id=provider_id,
  3029. nonce=nonce,
  3030. code_verifier=code_verifier,
  3031. expires_at=datetime.now(timezone.utc) + timedelta(minutes=5),
  3032. )
  3033. )
  3034. await db_session.commit()
  3035. discovery_doc = {
  3036. "issuer": issuer,
  3037. "authorization_endpoint": f"{issuer}/auth",
  3038. "token_endpoint": f"{issuer}/token",
  3039. "jwks_uri": f"{issuer}/.well-known/jwks.json",
  3040. }
  3041. token_response = {"access_token": "mock-access", "token_type": "Bearer", "id_token": id_token}
  3042. class _R:
  3043. def __init__(self, data):
  3044. self._data = data
  3045. self.status_code = 200
  3046. self.is_success = True
  3047. self.text = str(data)
  3048. def json(self):
  3049. return self._data
  3050. def raise_for_status(self):
  3051. pass
  3052. class _C:
  3053. def __init__(self, *a, **kw):
  3054. pass
  3055. async def __aenter__(self):
  3056. return self
  3057. async def __aexit__(self, *a):
  3058. pass
  3059. async def get(self, url, **kw):
  3060. return _R(jwks_data if "jwks" in url else discovery_doc)
  3061. async def post(self, url, **kw):
  3062. return _R(token_response)
  3063. with patch("backend.app.api.routes.mfa.httpx.AsyncClient", _C):
  3064. resp = await async_client.get(
  3065. f"/api/v1/auth/oidc/callback?code=test-code&state={state}",
  3066. follow_redirects=False,
  3067. )
  3068. return resp.headers.get("location", "")
  3069. class TestOIDCEmailClaimResolution:
  3070. """Three-case email resolution logic: Fall A / Fall B / Fall C."""
  3071. # ── shared helpers ────────────────────────────────────────────────────────
  3072. async def _create_provider(
  3073. self,
  3074. async_client: AsyncClient,
  3075. admin_token: str,
  3076. issuer: str,
  3077. client_id: str,
  3078. *,
  3079. email_claim: str = "email",
  3080. require_email_verified: bool = True,
  3081. auto_link_existing_accounts: bool = False,
  3082. suffix: str = "",
  3083. ) -> int:
  3084. resp = await async_client.post(
  3085. "/api/v1/auth/oidc/providers",
  3086. json={
  3087. "name": f"TestIdP-{suffix or secrets.token_hex(4)}",
  3088. "issuer_url": issuer,
  3089. "client_id": client_id,
  3090. "client_secret": "sec",
  3091. "scopes": "openid email profile",
  3092. "is_enabled": True,
  3093. "auto_create_users": True,
  3094. "auto_link_existing_accounts": auto_link_existing_accounts,
  3095. "email_claim": email_claim,
  3096. "require_email_verified": require_email_verified,
  3097. },
  3098. headers={"Authorization": f"Bearer {admin_token}"},
  3099. )
  3100. assert resp.status_code == 201, resp.text
  3101. return resp.json()["id"]
  3102. async def _get_oidc_link(self, db_session: AsyncSession, provider_id: int, sub: str):
  3103. from sqlalchemy import select
  3104. from backend.app.models.oidc_provider import UserOIDCLink
  3105. result = await db_session.execute(
  3106. select(UserOIDCLink)
  3107. .where(UserOIDCLink.provider_id == provider_id)
  3108. .where(UserOIDCLink.provider_user_id == sub)
  3109. )
  3110. return result.scalar_one_or_none()
  3111. # ── Parametrized matrix: Fall A / Fall B / Fall C ─────────────────────────
  3112. @pytest.mark.asyncio
  3113. @pytest.mark.integration
  3114. @pytest.mark.parametrize(
  3115. "email_claim,require_ev,claims,expected",
  3116. [
  3117. # Fall A: standard claim + require_ev=True (default)
  3118. ("email", True, {"email": "fa@example.com", "email_verified": True}, "fa@example.com"),
  3119. ("email", True, {"email": "fa@example.com", "email_verified": False}, None),
  3120. ("email", True, {"email": "fa@example.com"}, None), # Azure Entra with default config
  3121. # Fall A + SEC-2: malformed email claim rejected even when email_verified=True
  3122. ("email", True, {"email": "notanemail", "email_verified": True}, None),
  3123. # Fall B: standard claim + require_ev=False (Azure Entra permissive)
  3124. ("email", False, {"email": "fb@example.com", "email_verified": True}, "fb@example.com"),
  3125. ("email", False, {"email": "fb@example.com", "email_verified": False}, None),
  3126. ("email", False, {"email": "azure@company.com"}, "azure@company.com"), # ev absent → kept
  3127. # Fall B + SEC-2: malformed email claim rejected in permissive mode (ev absent)
  3128. ("email", False, {"email": "user@nodot"}, None),
  3129. # Fall B + SEC-2: shape check fires before email_verified=False drop
  3130. ("email", False, {"email": "notanemail", "email_verified": False}, None),
  3131. # Fall C: custom claim (preferred_username) — no email_verified check
  3132. ("preferred_username", True, {"preferred_username": "User@Company.COM"}, "user@company.com"),
  3133. ("preferred_username", True, {"preferred_username": " User@EXAMPLE.COM "}, "user@example.com"),
  3134. ("preferred_username", True, {"preferred_username": "justausername"}, None),
  3135. ("preferred_username", True, {"preferred_username": "@"}, None), # SEC-2: "@" only
  3136. ("preferred_username", True, {"preferred_username": "@domain.com"}, None), # SEC-2: empty local
  3137. ("preferred_username", True, {"preferred_username": "user@"}, None), # SEC-2: empty domain
  3138. ("preferred_username", True, {"preferred_username": "user@nodot"}, None), # SEC-2: no dot in domain
  3139. ("preferred_username", True, {}, None), # claim absent
  3140. # Fall C: email_verified=False present alongside custom claim — must NOT suppress the email
  3141. ("preferred_username", True, {"preferred_username": "user@co.com", "email_verified": False}, "user@co.com"),
  3142. ],
  3143. ids=[
  3144. "fall-a-ev-true",
  3145. "fall-a-ev-false",
  3146. "fall-a-ev-absent",
  3147. "fall-a-malformed-email",
  3148. "fall-b-ev-true",
  3149. "fall-b-ev-false",
  3150. "fall-b-ev-absent",
  3151. "fall-b-malformed-email",
  3152. "fall-b-malformed-email-ev-false",
  3153. "fall-c-valid-upn",
  3154. "fall-c-lowercase-strip",
  3155. "fall-c-no-at",
  3156. "fall-c-at-only",
  3157. "fall-c-empty-local",
  3158. "fall-c-empty-domain",
  3159. "fall-c-no-dot-in-domain",
  3160. "fall-c-claim-absent",
  3161. "fall-c-ev-false-ignored",
  3162. ],
  3163. )
  3164. async def test_email_resolution_matrix(
  3165. self,
  3166. async_client: AsyncClient,
  3167. db_session: AsyncSession,
  3168. email_claim: str,
  3169. require_ev: bool,
  3170. claims: dict,
  3171. expected: str | None,
  3172. ):
  3173. """C4: Verify link exists AND check provider_email — avoids false-passing on callback failure."""
  3174. issuer = "https://matrix.test"
  3175. client_id = "matrix-client"
  3176. admin_token = await _setup_and_login(async_client, "matrix_adm", "Matrix123!")
  3177. private_pem, jwks_data = _make_test_rsa_key()
  3178. provider_id = await self._create_provider(
  3179. async_client,
  3180. admin_token,
  3181. issuer,
  3182. client_id,
  3183. email_claim=email_claim,
  3184. require_email_verified=require_ev,
  3185. suffix="matrix",
  3186. )
  3187. sub = f"sub-matrix-{secrets.token_hex(6)}"
  3188. await _run_oidc_callback(
  3189. async_client,
  3190. db_session,
  3191. provider_id=provider_id,
  3192. claims={"sub": sub, **claims},
  3193. private_pem=private_pem,
  3194. jwks_data=jwks_data,
  3195. issuer=issuer,
  3196. client_id=client_id,
  3197. )
  3198. db_session.expire_all()
  3199. link = await self._get_oidc_link(db_session, provider_id, sub)
  3200. assert link is not None, "UserOIDCLink must be created even when email is dropped"
  3201. assert link.provider_email == expected
  3202. # ── Security: auto_link guards (CREATE endpoint) ──────────────────────────
  3203. @pytest.mark.asyncio
  3204. @pytest.mark.integration
  3205. async def test_auto_link_blocked_with_require_ev_false(self, async_client: AsyncClient):
  3206. """SEC-1: auto_link + require_email_verified=False must be rejected at schema level (422)."""
  3207. admin_token = await _setup_and_login(async_client, "sec1_adm", "Sec1Adm123!")
  3208. resp = await async_client.post(
  3209. "/api/v1/auth/oidc/providers",
  3210. json={
  3211. "name": "SEC1-Test",
  3212. "issuer_url": "https://sec1.test",
  3213. "client_id": "sec1-client",
  3214. "client_secret": "sec",
  3215. "scopes": "openid email profile",
  3216. "auto_link_existing_accounts": True,
  3217. "require_email_verified": False,
  3218. },
  3219. headers={"Authorization": f"Bearer {admin_token}"},
  3220. )
  3221. assert resp.status_code == 422
  3222. @pytest.mark.asyncio
  3223. @pytest.mark.integration
  3224. async def test_auto_link_allowed_with_custom_claim_create(self, async_client: AsyncClient):
  3225. """Fall C: auto_link + email_claim!='email' must be accepted on CREATE (201).
  3226. Custom claims (e.g. Azure preferred_username/upn) never perform an email_verified
  3227. check, so auto_link is safe regardless of require_email_verified.
  3228. """
  3229. admin_token = await _setup_and_login(async_client, "sec6c_adm", "Sec6CAdm123!")
  3230. resp = await async_client.post(
  3231. "/api/v1/auth/oidc/providers",
  3232. json={
  3233. "name": "SEC6-Create-Test",
  3234. "issuer_url": "https://sec6c.test",
  3235. "client_id": "sec6c-client",
  3236. "client_secret": "sec",
  3237. "scopes": "openid email profile",
  3238. "auto_link_existing_accounts": True,
  3239. "email_claim": "upn",
  3240. },
  3241. headers={"Authorization": f"Bearer {admin_token}"},
  3242. )
  3243. assert resp.status_code == 201
  3244. assert resp.json()["auto_link_existing_accounts"] is True
  3245. assert resp.json()["email_claim"] == "upn"
  3246. @pytest.mark.asyncio
  3247. @pytest.mark.integration
  3248. async def test_auto_link_allowed_with_custom_claim_update(self, async_client: AsyncClient):
  3249. """Fall C: auto_link=True + email_claim='upn' in same UPDATE request → 200.
  3250. Custom claims never perform an email_verified check, so auto_link is safe.
  3251. """
  3252. admin_token = await _setup_and_login(async_client, "sec6u_adm", "Sec6UAdm123!")
  3253. create_resp = await async_client.post(
  3254. "/api/v1/auth/oidc/providers",
  3255. json={
  3256. "name": "SEC6-Update-Test",
  3257. "issuer_url": "https://sec6u.test",
  3258. "client_id": "sec6u-client",
  3259. "client_secret": "sec",
  3260. "scopes": "openid email profile",
  3261. },
  3262. headers={"Authorization": f"Bearer {admin_token}"},
  3263. )
  3264. assert create_resp.status_code == 201
  3265. provider_id = create_resp.json()["id"]
  3266. resp = await async_client.put(
  3267. f"/api/v1/auth/oidc/providers/{provider_id}",
  3268. json={"auto_link_existing_accounts": True, "email_claim": "upn"},
  3269. headers={"Authorization": f"Bearer {admin_token}"},
  3270. )
  3271. assert resp.status_code == 200
  3272. assert resp.json()["auto_link_existing_accounts"] is True
  3273. assert resp.json()["email_claim"] == "upn"
  3274. # ── Combined-State-Guard (partial updates across two requests) ─────────────
  3275. @pytest.mark.asyncio
  3276. @pytest.mark.integration
  3277. async def test_partial_update_guard_require_ev(self, async_client: AsyncClient):
  3278. """SEC-1 Combined-State-Guard: require_ev=False then auto_link=True → 422 (T1 require_ev path)."""
  3279. admin_token = await _setup_and_login(async_client, "pg_rev_adm", "PgRev123!")
  3280. create_resp = await async_client.post(
  3281. "/api/v1/auth/oidc/providers",
  3282. json={
  3283. "name": "PG-RequireEV-Test",
  3284. "issuer_url": "https://pg-rev.test",
  3285. "client_id": "pg-rev-client",
  3286. "client_secret": "sec",
  3287. "scopes": "openid email profile",
  3288. },
  3289. headers={"Authorization": f"Bearer {admin_token}"},
  3290. )
  3291. assert create_resp.status_code == 201
  3292. provider_id = create_resp.json()["id"]
  3293. upd1 = await async_client.put(
  3294. f"/api/v1/auth/oidc/providers/{provider_id}",
  3295. json={"require_email_verified": False},
  3296. headers={"Authorization": f"Bearer {admin_token}"},
  3297. )
  3298. assert upd1.status_code == 200
  3299. upd2 = await async_client.put(
  3300. f"/api/v1/auth/oidc/providers/{provider_id}",
  3301. json={"auto_link_existing_accounts": True},
  3302. headers={"Authorization": f"Bearer {admin_token}"},
  3303. )
  3304. assert upd2.status_code == 422
  3305. @pytest.mark.asyncio
  3306. @pytest.mark.integration
  3307. async def test_partial_update_custom_claim_then_auto_link_allowed(self, async_client: AsyncClient):
  3308. """Fall C: email_claim='upn' first, then auto_link=True → both 200 (custom claim is safe)."""
  3309. admin_token = await _setup_and_login(async_client, "pg_ec_adm", "PgEc123!")
  3310. create_resp = await async_client.post(
  3311. "/api/v1/auth/oidc/providers",
  3312. json={
  3313. "name": "PG-EmailClaim-Test",
  3314. "issuer_url": "https://pg-ec.test",
  3315. "client_id": "pg-ec-client",
  3316. "client_secret": "sec",
  3317. "scopes": "openid email profile",
  3318. },
  3319. headers={"Authorization": f"Bearer {admin_token}"},
  3320. )
  3321. assert create_resp.status_code == 201
  3322. provider_id = create_resp.json()["id"]
  3323. upd1 = await async_client.put(
  3324. f"/api/v1/auth/oidc/providers/{provider_id}",
  3325. json={"email_claim": "upn"},
  3326. headers={"Authorization": f"Bearer {admin_token}"},
  3327. )
  3328. assert upd1.status_code == 200
  3329. upd2 = await async_client.put(
  3330. f"/api/v1/auth/oidc/providers/{provider_id}",
  3331. json={"auto_link_existing_accounts": True},
  3332. headers={"Authorization": f"Bearer {admin_token}"},
  3333. )
  3334. assert upd2.status_code == 200
  3335. assert upd2.json()["auto_link_existing_accounts"] is True
  3336. assert upd2.json()["email_claim"] == "upn"
  3337. @pytest.mark.asyncio
  3338. @pytest.mark.integration
  3339. async def test_partial_update_auto_link_then_custom_claim_allowed(self, async_client: AsyncClient):
  3340. """Fall C: auto_link=True first (email_claim='email', safe), then email_claim='upn' → both 200."""
  3341. admin_token = await _setup_and_login(async_client, "pg_al_ec_adm", "PgAlEc123!")
  3342. create_resp = await async_client.post(
  3343. "/api/v1/auth/oidc/providers",
  3344. json={
  3345. "name": "PG-AutoLink-Claim-Test",
  3346. "issuer_url": "https://pg-al-ec.test",
  3347. "client_id": "pg-al-ec-client",
  3348. "client_secret": "sec",
  3349. "scopes": "openid email profile",
  3350. },
  3351. headers={"Authorization": f"Bearer {admin_token}"},
  3352. )
  3353. assert create_resp.status_code == 201
  3354. provider_id = create_resp.json()["id"]
  3355. upd1 = await async_client.put(
  3356. f"/api/v1/auth/oidc/providers/{provider_id}",
  3357. json={"auto_link_existing_accounts": True},
  3358. headers={"Authorization": f"Bearer {admin_token}"},
  3359. )
  3360. assert upd1.status_code == 200
  3361. upd2 = await async_client.put(
  3362. f"/api/v1/auth/oidc/providers/{provider_id}",
  3363. json={"email_claim": "preferred_username"},
  3364. headers={"Authorization": f"Bearer {admin_token}"},
  3365. )
  3366. assert upd2.status_code == 200
  3367. assert upd2.json()["auto_link_existing_accounts"] is True
  3368. assert upd2.json()["email_claim"] == "preferred_username"
  3369. @pytest.mark.asyncio
  3370. @pytest.mark.integration
  3371. async def test_partial_update_guard_inverse_order(self, async_client: AsyncClient):
  3372. """T2: auto_link=True first (valid), then require_ev=False → Combined-State-Guard fires (422)."""
  3373. admin_token = await _setup_and_login(async_client, "pg_inv_adm", "PgInv123!")
  3374. create_resp = await async_client.post(
  3375. "/api/v1/auth/oidc/providers",
  3376. json={
  3377. "name": "PG-Inverse-Test",
  3378. "issuer_url": "https://pg-inv.test",
  3379. "client_id": "pg-inv-client",
  3380. "client_secret": "sec",
  3381. "scopes": "openid email profile",
  3382. },
  3383. headers={"Authorization": f"Bearer {admin_token}"},
  3384. )
  3385. assert create_resp.status_code == 201
  3386. provider_id = create_resp.json()["id"]
  3387. # auto_link=True is safe when require_ev=True (default)
  3388. upd1 = await async_client.put(
  3389. f"/api/v1/auth/oidc/providers/{provider_id}",
  3390. json={"auto_link_existing_accounts": True},
  3391. headers={"Authorization": f"Bearer {admin_token}"},
  3392. )
  3393. assert upd1.status_code == 200
  3394. # Disabling require_ev with auto_link already on → unsafe combined state
  3395. upd2 = await async_client.put(
  3396. f"/api/v1/auth/oidc/providers/{provider_id}",
  3397. json={"require_email_verified": False},
  3398. headers={"Authorization": f"Bearer {admin_token}"},
  3399. )
  3400. assert upd2.status_code == 422
  3401. # ── Low5: Response fields verified ───────────────────────────────────────
  3402. @pytest.mark.asyncio
  3403. @pytest.mark.integration
  3404. async def test_create_response_includes_new_fields(self, async_client: AsyncClient):
  3405. """Low5: OIDCProviderResponse must include email_claim and require_email_verified."""
  3406. admin_token = await _setup_and_login(async_client, "resp_adm", "Resp123!")
  3407. resp = await async_client.post(
  3408. "/api/v1/auth/oidc/providers",
  3409. json={
  3410. "name": "ResponseFields-Test",
  3411. "issuer_url": "https://resp.test",
  3412. "client_id": "resp-client",
  3413. "client_secret": "sec",
  3414. "scopes": "openid email profile",
  3415. "email_claim": "preferred_username",
  3416. "require_email_verified": False,
  3417. },
  3418. headers={"Authorization": f"Bearer {admin_token}"},
  3419. )
  3420. assert resp.status_code == 201
  3421. data = resp.json()
  3422. assert data["email_claim"] == "preferred_username"
  3423. assert data["require_email_verified"] is False
  3424. @pytest.mark.asyncio
  3425. @pytest.mark.integration
  3426. async def test_update_response_reflects_new_fields(self, async_client: AsyncClient):
  3427. """Low5: PUT response must reflect updated email_claim and require_email_verified."""
  3428. admin_token = await _setup_and_login(async_client, "upd_resp_adm", "UpdResp123!")
  3429. create_resp = await async_client.post(
  3430. "/api/v1/auth/oidc/providers",
  3431. json={
  3432. "name": "UpdateResponse-Test",
  3433. "issuer_url": "https://upd-resp.test",
  3434. "client_id": "upd-resp-client",
  3435. "client_secret": "sec",
  3436. "scopes": "openid email profile",
  3437. },
  3438. headers={"Authorization": f"Bearer {admin_token}"},
  3439. )
  3440. assert create_resp.status_code == 201
  3441. provider_id = create_resp.json()["id"]
  3442. upd = await async_client.put(
  3443. f"/api/v1/auth/oidc/providers/{provider_id}",
  3444. json={"email_claim": "upn", "require_email_verified": True},
  3445. headers={"Authorization": f"Bearer {admin_token}"},
  3446. )
  3447. assert upd.status_code == 200
  3448. data = upd.json()
  3449. assert data["email_claim"] == "upn"
  3450. assert data["require_email_verified"] is True
  3451. # ===========================================================================
  3452. # TestOIDCEmailClaimValidation — T2: email_claim field validator coverage
  3453. # ===========================================================================
  3454. class TestOIDCEmailClaimValidation:
  3455. """Schema-level validation for the email_claim field."""
  3456. @pytest.mark.asyncio
  3457. @pytest.mark.integration
  3458. async def test_invalid_claim_name_dot_rejected(self, async_client: AsyncClient):
  3459. """email_claim with a dot (log-injection risk) must be rejected."""
  3460. admin_token = await _setup_and_login(async_client, "ecv_adm1", "Ecv123!")
  3461. resp = await async_client.post(
  3462. "/api/v1/auth/oidc/providers",
  3463. json={
  3464. "name": "ECVTest1",
  3465. "issuer_url": "https://ecv1.test",
  3466. "client_id": "ecv1",
  3467. "client_secret": "sec",
  3468. "scopes": "openid email",
  3469. "email_claim": "email.address",
  3470. },
  3471. headers={"Authorization": f"Bearer {admin_token}"},
  3472. )
  3473. assert resp.status_code == 422
  3474. @pytest.mark.asyncio
  3475. @pytest.mark.integration
  3476. async def test_invalid_claim_name_starts_with_digit_rejected(self, async_client: AsyncClient):
  3477. admin_token = await _setup_and_login(async_client, "ecv_adm2", "Ecv123!")
  3478. resp = await async_client.post(
  3479. "/api/v1/auth/oidc/providers",
  3480. json={
  3481. "name": "ECVTest2",
  3482. "issuer_url": "https://ecv2.test",
  3483. "client_id": "ecv2",
  3484. "client_secret": "sec",
  3485. "scopes": "openid email",
  3486. "email_claim": "1invalid",
  3487. },
  3488. headers={"Authorization": f"Bearer {admin_token}"},
  3489. )
  3490. assert resp.status_code == 422
  3491. @pytest.mark.asyncio
  3492. @pytest.mark.integration
  3493. async def test_invalid_claim_name_newline_rejected(self, async_client: AsyncClient):
  3494. """T2 regex-bug guard: re.fullmatch must reject trailing newline."""
  3495. admin_token = await _setup_and_login(async_client, "ecv_adm3", "Ecv123!")
  3496. resp = await async_client.post(
  3497. "/api/v1/auth/oidc/providers",
  3498. json={
  3499. "name": "ECVTest3",
  3500. "issuer_url": "https://ecv3.test",
  3501. "client_id": "ecv3",
  3502. "client_secret": "sec",
  3503. "scopes": "openid email",
  3504. "email_claim": "email\n",
  3505. },
  3506. headers={"Authorization": f"Bearer {admin_token}"},
  3507. )
  3508. assert resp.status_code == 422
  3509. @pytest.mark.asyncio
  3510. @pytest.mark.integration
  3511. async def test_claim_name_65_chars_rejected(self, async_client: AsyncClient):
  3512. """email_claim longer than 64 characters must be rejected."""
  3513. admin_token = await _setup_and_login(async_client, "ecv_adm4", "Ecv123!")
  3514. resp = await async_client.post(
  3515. "/api/v1/auth/oidc/providers",
  3516. json={
  3517. "name": "ECVTest4",
  3518. "issuer_url": "https://ecv4.test",
  3519. "client_id": "ecv4",
  3520. "client_secret": "sec",
  3521. "scopes": "openid email",
  3522. "email_claim": "a" * 65,
  3523. },
  3524. headers={"Authorization": f"Bearer {admin_token}"},
  3525. )
  3526. assert resp.status_code == 422
  3527. @pytest.mark.asyncio
  3528. @pytest.mark.integration
  3529. async def test_valid_claim_name_accepted(self, async_client: AsyncClient):
  3530. """Valid claim names like preferred_username and upn must be accepted."""
  3531. admin_token = await _setup_and_login(async_client, "ecv_adm5", "Ecv123!")
  3532. for claim in ("preferred_username", "upn", "email", "emailAddress"):
  3533. resp = await async_client.post(
  3534. "/api/v1/auth/oidc/providers",
  3535. json={
  3536. "name": f"ECVTest-{claim[:8]}",
  3537. "issuer_url": f"https://ecv-{claim[:8]}.test",
  3538. "client_id": f"ecv-{claim[:8]}",
  3539. "client_secret": "sec",
  3540. "scopes": "openid email",
  3541. "email_claim": claim,
  3542. },
  3543. headers={"Authorization": f"Bearer {admin_token}"},
  3544. )
  3545. assert resp.status_code == 201, f"claim {claim!r} was rejected: {resp.text}"
  3546. # ===========================================================================
  3547. # TestOIDCEmailResolutionExtra — T1 / T3 / T4 additional coverage
  3548. # ===========================================================================
  3549. async def _create_provider_via_api(
  3550. async_client: AsyncClient,
  3551. admin_token: str,
  3552. issuer: str,
  3553. client_id: str,
  3554. *,
  3555. email_claim: str = "email",
  3556. require_email_verified: bool = True,
  3557. suffix: str = "",
  3558. ) -> int:
  3559. resp = await async_client.post(
  3560. "/api/v1/auth/oidc/providers",
  3561. json={
  3562. "name": f"TestIdP-extra-{suffix or secrets.token_hex(4)}",
  3563. "issuer_url": issuer,
  3564. "client_id": client_id,
  3565. "client_secret": "sec",
  3566. "scopes": "openid email profile",
  3567. "is_enabled": True,
  3568. "auto_create_users": True,
  3569. "email_claim": email_claim,
  3570. "require_email_verified": require_email_verified,
  3571. },
  3572. headers={"Authorization": f"Bearer {admin_token}"},
  3573. )
  3574. assert resp.status_code == 201, resp.text
  3575. return resp.json()["id"]
  3576. class TestOIDCEmailResolutionExtra:
  3577. """T1: isinstance guard, T3: SEC-3 normalisation for Fall A/B, T4: inverse Combined-State-Guard."""
  3578. @pytest.mark.asyncio
  3579. @pytest.mark.integration
  3580. async def test_non_string_claim_value_drops_email(
  3581. self,
  3582. async_client: AsyncClient,
  3583. db_session: AsyncSession,
  3584. ):
  3585. """T1: A non-string email_claim value (list) must be silently dropped — no crash."""
  3586. private_pem, jwks_data = _make_test_rsa_key()
  3587. issuer = "https://nonstring-test.example"
  3588. client_id = "nonstring-client"
  3589. admin_token = await _setup_and_login(async_client, "nonstr_adm", "Nonstr123!")
  3590. provider_id = await _create_provider_via_api(
  3591. async_client,
  3592. admin_token,
  3593. issuer,
  3594. client_id,
  3595. email_claim="preferred_username",
  3596. require_email_verified=False,
  3597. suffix="nonstr",
  3598. )
  3599. from sqlalchemy import select
  3600. from backend.app.models.oidc_provider import UserOIDCLink
  3601. # IdP sends preferred_username as a list (non-string) — must not crash
  3602. location = await _run_oidc_callback(
  3603. async_client,
  3604. db_session,
  3605. provider_id=provider_id,
  3606. claims={"sub": "nonstr-sub-1", "preferred_username": ["user@example.com"]},
  3607. private_pem=private_pem,
  3608. jwks_data=jwks_data,
  3609. issuer=issuer,
  3610. client_id=client_id,
  3611. )
  3612. assert "internal_error" not in location, f"Unexpected error redirect: {location}"
  3613. link_result = await db_session.execute(
  3614. select(UserOIDCLink)
  3615. .where(UserOIDCLink.provider_id == provider_id)
  3616. .where(UserOIDCLink.provider_user_id == "nonstr-sub-1")
  3617. )
  3618. link = link_result.scalar_one_or_none()
  3619. assert link is not None
  3620. assert link.provider_email is None # list value dropped
  3621. @pytest.mark.asyncio
  3622. @pytest.mark.integration
  3623. async def test_fall_a_sec3_normalisation(
  3624. self,
  3625. async_client: AsyncClient,
  3626. db_session: AsyncSession,
  3627. ):
  3628. """T3: Fall A — uppercase + whitespace in email claim must be normalised to lowercase."""
  3629. private_pem, jwks_data = _make_test_rsa_key()
  3630. issuer = "https://sec3a-test.example"
  3631. client_id = "sec3a-client"
  3632. admin_token = await _setup_and_login(async_client, "sec3a_adm", "Sec3a123!")
  3633. provider_id = await _create_provider_via_api(
  3634. async_client,
  3635. admin_token,
  3636. issuer,
  3637. client_id,
  3638. email_claim="email",
  3639. require_email_verified=True,
  3640. suffix="sec3a",
  3641. )
  3642. from sqlalchemy import select
  3643. from backend.app.models.oidc_provider import UserOIDCLink
  3644. location = await _run_oidc_callback(
  3645. async_client,
  3646. db_session,
  3647. provider_id=provider_id,
  3648. claims={"sub": "sec3a-sub-1", "email": " USER@EXAMPLE.COM ", "email_verified": True},
  3649. private_pem=private_pem,
  3650. jwks_data=jwks_data,
  3651. issuer=issuer,
  3652. client_id=client_id,
  3653. )
  3654. assert "internal_error" not in location
  3655. link_result = await db_session.execute(
  3656. select(UserOIDCLink)
  3657. .where(UserOIDCLink.provider_id == provider_id)
  3658. .where(UserOIDCLink.provider_user_id == "sec3a-sub-1")
  3659. )
  3660. link = link_result.scalar_one_or_none()
  3661. assert link is not None
  3662. assert link.provider_email == "user@example.com"
  3663. @pytest.mark.asyncio
  3664. @pytest.mark.integration
  3665. async def test_fall_b_sec3_normalisation(
  3666. self,
  3667. async_client: AsyncClient,
  3668. db_session: AsyncSession,
  3669. ):
  3670. """T3: Fall B — uppercase + whitespace in email claim must be normalised to lowercase."""
  3671. private_pem, jwks_data = _make_test_rsa_key()
  3672. issuer = "https://sec3b-test.example"
  3673. client_id = "sec3b-client"
  3674. admin_token = await _setup_and_login(async_client, "sec3b_adm", "Sec3b123!")
  3675. provider_id = await _create_provider_via_api(
  3676. async_client,
  3677. admin_token,
  3678. issuer,
  3679. client_id,
  3680. email_claim="email",
  3681. require_email_verified=False,
  3682. suffix="sec3b",
  3683. )
  3684. from sqlalchemy import select
  3685. from backend.app.models.oidc_provider import UserOIDCLink
  3686. location = await _run_oidc_callback(
  3687. async_client,
  3688. db_session,
  3689. provider_id=provider_id,
  3690. claims={"sub": "sec3b-sub-1", "email": " USER@EXAMPLE.COM "},
  3691. private_pem=private_pem,
  3692. jwks_data=jwks_data,
  3693. issuer=issuer,
  3694. client_id=client_id,
  3695. )
  3696. assert "internal_error" not in location
  3697. link_result = await db_session.execute(
  3698. select(UserOIDCLink)
  3699. .where(UserOIDCLink.provider_id == provider_id)
  3700. .where(UserOIDCLink.provider_user_id == "sec3b-sub-1")
  3701. )
  3702. link = link_result.scalar_one_or_none()
  3703. assert link is not None
  3704. assert link.provider_email == "user@example.com"
  3705. @pytest.mark.asyncio
  3706. @pytest.mark.integration
  3707. async def test_combined_state_guard_email_claim_inverse_order(self, async_client: AsyncClient):
  3708. """Fall C: auto_link=True first, then switch email_claim to custom → both 200 (now allowed).
  3709. Custom claims never perform an email_verified check, so switching to a custom claim
  3710. while auto_link is on transitions from Fall A to Fall C — both are safe.
  3711. """
  3712. admin_token = await _setup_and_login(async_client, "inv_ec_adm", "InvEc123!")
  3713. create_resp = await async_client.post(
  3714. "/api/v1/auth/oidc/providers",
  3715. json={
  3716. "name": "InvEcTest",
  3717. "issuer_url": "https://inv-ec.test",
  3718. "client_id": "inv-ec",
  3719. "client_secret": "sec",
  3720. "scopes": "openid email",
  3721. },
  3722. headers={"Authorization": f"Bearer {admin_token}"},
  3723. )
  3724. assert create_resp.status_code == 201
  3725. provider_id = create_resp.json()["id"]
  3726. # First: enable auto_link (Fall A — email_claim='email', require_ev=True)
  3727. upd1 = await async_client.put(
  3728. f"/api/v1/auth/oidc/providers/{provider_id}",
  3729. json={"auto_link_existing_accounts": True},
  3730. headers={"Authorization": f"Bearer {admin_token}"},
  3731. )
  3732. assert upd1.status_code == 200
  3733. # Second: switch to custom claim → Fall C, still safe
  3734. upd2 = await async_client.put(
  3735. f"/api/v1/auth/oidc/providers/{provider_id}",
  3736. json={"email_claim": "preferred_username"},
  3737. headers={"Authorization": f"Bearer {admin_token}"},
  3738. )
  3739. assert upd2.status_code == 200
  3740. assert upd2.json()["auto_link_existing_accounts"] is True
  3741. assert upd2.json()["email_claim"] == "preferred_username"
  3742. # ===========================================================================
  3743. # Issue #1569: standard 'email' claim fallback for User.email during
  3744. # auto-provisioning when email_claim is a non-email identity claim.
  3745. # ===========================================================================
  3746. class TestOIDCStandardEmailFallback:
  3747. """Issue #1569: when email_claim is set to a non-email identity claim
  3748. (e.g. preferred_username on Authentik) and the IdP token also carries a
  3749. standard 'email' claim, auto-created users get User.email from the
  3750. standard claim — without affecting the auto-link gate."""
  3751. async def _get_user_and_link(self, db_session: AsyncSession, provider_id: int, sub: str):
  3752. from sqlalchemy import select
  3753. from backend.app.models.oidc_provider import UserOIDCLink
  3754. from backend.app.models.user import User as UserModel
  3755. link_result = await db_session.execute(
  3756. select(UserOIDCLink)
  3757. .where(UserOIDCLink.provider_id == provider_id)
  3758. .where(UserOIDCLink.provider_user_id == sub)
  3759. )
  3760. link = link_result.scalar_one_or_none()
  3761. if link is None:
  3762. return None, None
  3763. user_result = await db_session.execute(select(UserModel).where(UserModel.id == link.user_id))
  3764. return user_result.scalar_one_or_none(), link
  3765. @pytest.mark.asyncio
  3766. @pytest.mark.integration
  3767. async def test_email_claim_preferred_username_falls_back_to_standard_email(
  3768. self,
  3769. async_client: AsyncClient,
  3770. db_session: AsyncSession,
  3771. ):
  3772. """email_claim=preferred_username + both claims → username from preferred_username,
  3773. email populated from the standard 'email' claim."""
  3774. private_pem, jwks_data = _make_test_rsa_key()
  3775. issuer = "https://issue1569-both.example"
  3776. client_id = "issue1569-both-client"
  3777. admin_token = await _setup_and_login(async_client, "i1569b_adm", "I1569b123!")
  3778. provider_id = await _create_provider_via_api(
  3779. async_client,
  3780. admin_token,
  3781. issuer,
  3782. client_id,
  3783. email_claim="preferred_username",
  3784. require_email_verified=False,
  3785. suffix="i1569both",
  3786. )
  3787. sub = f"sub-i1569-both-{secrets.token_hex(6)}"
  3788. await _run_oidc_callback(
  3789. async_client,
  3790. db_session,
  3791. provider_id=provider_id,
  3792. claims={
  3793. "sub": sub,
  3794. "preferred_username": "jdoe",
  3795. "email": "jdoe@example.com",
  3796. "email_verified": True,
  3797. },
  3798. private_pem=private_pem,
  3799. jwks_data=jwks_data,
  3800. issuer=issuer,
  3801. client_id=client_id,
  3802. )
  3803. db_session.expire_all()
  3804. user, link = await self._get_user_and_link(db_session, provider_id, sub)
  3805. assert user is not None and link is not None
  3806. assert user.username == "jdoe"
  3807. assert user.email == "jdoe@example.com"
  3808. assert link.provider_email == "jdoe@example.com"
  3809. @pytest.mark.asyncio
  3810. @pytest.mark.integration
  3811. async def test_email_claim_preferred_username_no_standard_email_keeps_email_none(
  3812. self,
  3813. async_client: AsyncClient,
  3814. db_session: AsyncSession,
  3815. ):
  3816. """email_claim=preferred_username + no standard 'email' claim → behaviour unchanged:
  3817. username derived from preferred_username, User.email stays None."""
  3818. private_pem, jwks_data = _make_test_rsa_key()
  3819. issuer = "https://issue1569-noemail.example"
  3820. client_id = "issue1569-noemail-client"
  3821. admin_token = await _setup_and_login(async_client, "i1569n_adm", "I1569n123!")
  3822. provider_id = await _create_provider_via_api(
  3823. async_client,
  3824. admin_token,
  3825. issuer,
  3826. client_id,
  3827. email_claim="preferred_username",
  3828. require_email_verified=False,
  3829. suffix="i1569none",
  3830. )
  3831. sub = f"sub-i1569-none-{secrets.token_hex(6)}"
  3832. await _run_oidc_callback(
  3833. async_client,
  3834. db_session,
  3835. provider_id=provider_id,
  3836. claims={"sub": sub, "preferred_username": "jdoe2"},
  3837. private_pem=private_pem,
  3838. jwks_data=jwks_data,
  3839. issuer=issuer,
  3840. client_id=client_id,
  3841. )
  3842. db_session.expire_all()
  3843. user, link = await self._get_user_and_link(db_session, provider_id, sub)
  3844. assert user is not None and link is not None
  3845. assert user.username == "jdoe2"
  3846. assert user.email is None
  3847. assert link.provider_email is None
  3848. @pytest.mark.asyncio
  3849. @pytest.mark.integration
  3850. async def test_fallback_respects_email_verified_false(
  3851. self,
  3852. async_client: AsyncClient,
  3853. db_session: AsyncSession,
  3854. ):
  3855. """email_claim=preferred_username + standard 'email' claim with email_verified=False
  3856. → fallback drops the email (User.email None) — matches Fall B semantics."""
  3857. private_pem, jwks_data = _make_test_rsa_key()
  3858. issuer = "https://issue1569-evfalse.example"
  3859. client_id = "issue1569-evfalse-client"
  3860. admin_token = await _setup_and_login(async_client, "i1569f_adm", "I1569f123!")
  3861. provider_id = await _create_provider_via_api(
  3862. async_client,
  3863. admin_token,
  3864. issuer,
  3865. client_id,
  3866. email_claim="preferred_username",
  3867. require_email_verified=False,
  3868. suffix="i1569evfalse",
  3869. )
  3870. sub = f"sub-i1569-evfalse-{secrets.token_hex(6)}"
  3871. await _run_oidc_callback(
  3872. async_client,
  3873. db_session,
  3874. provider_id=provider_id,
  3875. claims={
  3876. "sub": sub,
  3877. "preferred_username": "jdoe3",
  3878. "email": "jdoe3@example.com",
  3879. "email_verified": False,
  3880. },
  3881. private_pem=private_pem,
  3882. jwks_data=jwks_data,
  3883. issuer=issuer,
  3884. client_id=client_id,
  3885. )
  3886. db_session.expire_all()
  3887. user, link = await self._get_user_and_link(db_session, provider_id, sub)
  3888. assert user is not None and link is not None
  3889. assert user.username == "jdoe3"
  3890. assert user.email is None
  3891. assert link.provider_email is None
  3892. @pytest.mark.asyncio
  3893. @pytest.mark.integration
  3894. async def test_fallback_skipped_when_email_claim_is_email(
  3895. self,
  3896. async_client: AsyncClient,
  3897. db_session: AsyncSession,
  3898. ):
  3899. """email_claim='email' (default) — fallback path must NOT fire. Primary
  3900. resolver result is authoritative; this guards against the fallback
  3901. accidentally widening Fall-A/B semantics."""
  3902. private_pem, jwks_data = _make_test_rsa_key()
  3903. issuer = "https://issue1569-default.example"
  3904. client_id = "issue1569-default-client"
  3905. admin_token = await _setup_and_login(async_client, "i1569d_adm", "I1569d123!")
  3906. provider_id = await _create_provider_via_api(
  3907. async_client,
  3908. admin_token,
  3909. issuer,
  3910. client_id,
  3911. email_claim="email",
  3912. require_email_verified=True,
  3913. suffix="i1569default",
  3914. )
  3915. sub = f"sub-i1569-default-{secrets.token_hex(6)}"
  3916. # email_verified absent → Fall A drops it. Fallback must NOT
  3917. # re-instate the email since email_claim='email' is already the
  3918. # primary path.
  3919. await _run_oidc_callback(
  3920. async_client,
  3921. db_session,
  3922. provider_id=provider_id,
  3923. claims={"sub": sub, "email": "jdoe4@example.com"},
  3924. private_pem=private_pem,
  3925. jwks_data=jwks_data,
  3926. issuer=issuer,
  3927. client_id=client_id,
  3928. )
  3929. db_session.expire_all()
  3930. user, link = await self._get_user_and_link(db_session, provider_id, sub)
  3931. assert user is not None and link is not None
  3932. assert user.email is None
  3933. assert link.provider_email is None
  3934. # ===========================================================================
  3935. # E2E: Fall C (custom email claim) auto-link actually links existing user
  3936. # ===========================================================================
  3937. class TestOIDCFallCAutoLinkE2E:
  3938. """OIDC callback with email_claim='preferred_username' (Fall C / Azure Entra ID)
  3939. must auto-link an existing local user when auto_link_existing_accounts=True.
  3940. This test exercises _resolve_provider_email Fall C and the auto-link path in
  3941. oidc_callback — a regression in either would silently drop the link without
  3942. being caught by the configuration-layer tests.
  3943. """
  3944. @pytest.mark.asyncio
  3945. @pytest.mark.integration
  3946. async def test_fall_c_auto_link_links_existing_user_via_callback(
  3947. self, async_client: AsyncClient, db_session: AsyncSession
  3948. ):
  3949. from unittest.mock import AsyncMock, MagicMock, patch
  3950. from sqlalchemy import select as sa_select
  3951. from backend.app.core.auth import get_password_hash
  3952. from backend.app.models.oidc_provider import OIDCProvider, UserOIDCLink
  3953. issuer = "https://entra.fallc.example.com"
  3954. nonce = secrets.token_urlsafe(32)
  3955. code_verifier = secrets.token_urlsafe(48)
  3956. # ── 1. Local user that should be linked ──────────────────────────────
  3957. alice = User(
  3958. username="fallc_alice",
  3959. email="alice.fallc@example.com",
  3960. password_hash=get_password_hash(secrets.token_urlsafe(16)),
  3961. role="user",
  3962. is_active=True,
  3963. )
  3964. db_session.add(alice)
  3965. await db_session.flush()
  3966. # ── 2. Provider: Fall C config (preferred_username, no email_verified) ─
  3967. provider = OIDCProvider(
  3968. name="AzureEntraFallC",
  3969. issuer_url=issuer,
  3970. client_id="azure-client",
  3971. _client_secret_enc="azure-secret",
  3972. scopes="openid profile",
  3973. is_enabled=True,
  3974. auto_link_existing_accounts=True,
  3975. auto_create_users=False,
  3976. email_claim="preferred_username",
  3977. require_email_verified=False,
  3978. )
  3979. db_session.add(provider)
  3980. await db_session.flush()
  3981. # ── 3. OIDC state token ───────────────────────────────────────────────
  3982. state = secrets.token_urlsafe(32)
  3983. db_session.add(
  3984. AuthEphemeralToken(
  3985. token=state,
  3986. token_type="oidc_state",
  3987. provider_id=provider.id,
  3988. nonce=nonce,
  3989. code_verifier=code_verifier,
  3990. expires_at=datetime.now(timezone.utc) + timedelta(minutes=10),
  3991. )
  3992. )
  3993. await db_session.commit()
  3994. # ── 4. Mock HTTP + JWT ────────────────────────────────────────────────
  3995. fake_discovery = {
  3996. "issuer": issuer,
  3997. "token_endpoint": f"{issuer}/token",
  3998. "jwks_uri": f"{issuer}/.well-known/jwks.json",
  3999. }
  4000. fake_token = {"access_token": "acc_tok", "id_token": "fake.id.token"}
  4001. # Fall C: preferred_username carries the email; no email_verified key at all
  4002. fake_claims = {
  4003. "sub": "azure-sub-alice",
  4004. "preferred_username": "alice.fallc@example.com",
  4005. "iss": issuer,
  4006. "aud": "azure-client",
  4007. "nonce": nonce,
  4008. "exp": 9_999_999_999,
  4009. }
  4010. disc_resp = AsyncMock()
  4011. disc_resp.raise_for_status = MagicMock()
  4012. disc_resp.json = MagicMock(return_value=fake_discovery)
  4013. token_resp = AsyncMock()
  4014. token_resp.json = MagicMock(return_value=fake_token)
  4015. jwks_resp = AsyncMock()
  4016. jwks_resp.raise_for_status = MagicMock()
  4017. jwks_resp.json = MagicMock(return_value={})
  4018. mock_http = AsyncMock()
  4019. mock_http.get = AsyncMock(side_effect=[disc_resp, jwks_resp])
  4020. mock_http.post = AsyncMock(return_value=token_resp)
  4021. mock_signing_key = MagicMock()
  4022. mock_signing_key.key = "fake_key"
  4023. with (
  4024. patch("backend.app.api.routes.mfa.httpx.AsyncClient") as mock_httpx_cls,
  4025. patch("backend.app.api.routes.mfa.jwt.decode", return_value=fake_claims),
  4026. patch("backend.app.api.routes.mfa.PyJWKClient") as mock_jwks_cls,
  4027. ):
  4028. mock_httpx_cls.return_value.__aenter__ = AsyncMock(return_value=mock_http)
  4029. mock_httpx_cls.return_value.__aexit__ = AsyncMock(return_value=False)
  4030. mock_jwks_cls.return_value.get_signing_key_from_jwt.return_value = mock_signing_key
  4031. callback_resp = await async_client.get(
  4032. f"/api/v1/auth/oidc/callback?code=fake_code&state={state}",
  4033. follow_redirects=False,
  4034. )
  4035. assert callback_resp.status_code == 302, callback_resp.text
  4036. location = callback_resp.headers.get("location", "")
  4037. assert "oidc_token=" in location, f"Expected oidc_token in redirect, got: {location}"
  4038. # ── 5. Exchange token → full JWT ──────────────────────────────────────
  4039. oidc_exchange_token = location.split("oidc_token=")[1].split("&")[0].split("#")[-1]
  4040. exchange_resp = await async_client.post(
  4041. "/api/v1/auth/oidc/exchange",
  4042. json={"oidc_token": oidc_exchange_token},
  4043. )
  4044. assert exchange_resp.status_code == 200
  4045. assert exchange_resp.json()["user"]["username"] == "fallc_alice"
  4046. # ── 6. Verify UserOIDCLink was created in DB ──────────────────────────
  4047. async with db_session as s:
  4048. result = await s.execute(
  4049. sa_select(UserOIDCLink).where(
  4050. UserOIDCLink.user_id == alice.id,
  4051. UserOIDCLink.provider_id == provider.id,
  4052. )
  4053. )
  4054. link = result.scalar_one_or_none()
  4055. assert link is not None, "UserOIDCLink must have been created by auto-link"
  4056. assert link.provider_user_id == "azure-sub-alice"
  4057. class TestOIDCAutoCreateUsername:
  4058. """Username derivation priority for auto-created OIDC users (#1173).
  4059. Priority order: email local-part > preferred_username > name > provider_sub.
  4060. Covers: plain claim, spaces-sanitized, name fallback, sub fallback,
  4061. non-string isinstance guard, sanitizes-to-empty fallback, collision counter.
  4062. """
  4063. # ── shared helpers ───────────────────────────────────────────────────────
  4064. @staticmethod
  4065. async def _create_provider(async_client: AsyncClient, admin_token: str, issuer: str, client_id: str) -> int:
  4066. resp = await async_client.post(
  4067. "/api/v1/auth/oidc/providers",
  4068. json={
  4069. "name": f"AutoUser-{secrets.token_hex(4)}",
  4070. "issuer_url": issuer,
  4071. "client_id": client_id,
  4072. "client_secret": "secret",
  4073. "scopes": "openid profile",
  4074. "is_enabled": True,
  4075. "auto_create_users": True,
  4076. "email_claim": "email",
  4077. "require_email_verified": True,
  4078. },
  4079. headers={"Authorization": f"Bearer {admin_token}"},
  4080. )
  4081. assert resp.status_code == 201, resp.text
  4082. return resp.json()["id"]
  4083. @staticmethod
  4084. async def _exchange_username(async_client: AsyncClient, location: str) -> str:
  4085. assert "oidc_token=" in location, f"No oidc_token in redirect: {location}"
  4086. token = location.split("oidc_token=")[1].split("&")[0].split("#")[-1]
  4087. resp = await async_client.post("/api/v1/auth/oidc/exchange", json={"oidc_token": token})
  4088. assert resp.status_code == 200, resp.text
  4089. return resp.json()["user"]["username"]
  4090. # ── tests ────────────────────────────────────────────────────────────────
  4091. @pytest.mark.asyncio
  4092. @pytest.mark.integration
  4093. async def test_preferred_username_used_when_no_email(self, async_client: AsyncClient, db_session: AsyncSession):
  4094. """preferred_username='johndoe' → username 'johndoe' (no email claim present)."""
  4095. private_pem, jwks_data = _make_test_rsa_key()
  4096. issuer = "https://au-pref.example"
  4097. client_id = "au-pref-client"
  4098. admin_token = await _setup_and_login(async_client, "au_pref_adm", "AuPrefAdm1!")
  4099. provider_id = await self._create_provider(async_client, admin_token, issuer, client_id)
  4100. location = await _run_oidc_callback(
  4101. async_client,
  4102. db_session,
  4103. provider_id=provider_id,
  4104. claims={"sub": "pref-sub-1", "preferred_username": "johndoe"},
  4105. private_pem=private_pem,
  4106. jwks_data=jwks_data,
  4107. issuer=issuer,
  4108. client_id=client_id,
  4109. )
  4110. username = await self._exchange_username(async_client, location)
  4111. assert username == "johndoe"
  4112. @pytest.mark.asyncio
  4113. @pytest.mark.integration
  4114. async def test_preferred_username_spaces_sanitized(self, async_client: AsyncClient, db_session: AsyncSession):
  4115. """preferred_username='John Doe' → sanitized to 'JohnDoe'."""
  4116. private_pem, jwks_data = _make_test_rsa_key()
  4117. issuer = "https://au-spaces.example"
  4118. client_id = "au-spaces-client"
  4119. admin_token = await _setup_and_login(async_client, "au_spaces_adm", "AuSpacesAdm1!")
  4120. provider_id = await self._create_provider(async_client, admin_token, issuer, client_id)
  4121. location = await _run_oidc_callback(
  4122. async_client,
  4123. db_session,
  4124. provider_id=provider_id,
  4125. claims={"sub": "spaces-sub-1", "preferred_username": "John Doe"},
  4126. private_pem=private_pem,
  4127. jwks_data=jwks_data,
  4128. issuer=issuer,
  4129. client_id=client_id,
  4130. )
  4131. username = await self._exchange_username(async_client, location)
  4132. assert username == "JohnDoe"
  4133. @pytest.mark.asyncio
  4134. @pytest.mark.integration
  4135. async def test_name_claim_used_when_no_preferred_username(
  4136. self, async_client: AsyncClient, db_session: AsyncSession
  4137. ):
  4138. """name='Jane Smith', no preferred_username → username 'JaneSmith'."""
  4139. private_pem, jwks_data = _make_test_rsa_key()
  4140. issuer = "https://au-name.example"
  4141. client_id = "au-name-client"
  4142. admin_token = await _setup_and_login(async_client, "au_name_adm", "AuNameAdm1!")
  4143. provider_id = await self._create_provider(async_client, admin_token, issuer, client_id)
  4144. location = await _run_oidc_callback(
  4145. async_client,
  4146. db_session,
  4147. provider_id=provider_id,
  4148. claims={"sub": "name-sub-1", "name": "Jane Smith"},
  4149. private_pem=private_pem,
  4150. jwks_data=jwks_data,
  4151. issuer=issuer,
  4152. client_id=client_id,
  4153. )
  4154. username = await self._exchange_username(async_client, location)
  4155. assert username == "JaneSmith"
  4156. @pytest.mark.asyncio
  4157. @pytest.mark.integration
  4158. async def test_provider_sub_fallback_when_no_claims(self, async_client: AsyncClient, db_session: AsyncSession):
  4159. """No preferred_username, no name, no email → username derived from provider_sub."""
  4160. private_pem, jwks_data = _make_test_rsa_key()
  4161. issuer = "https://au-sub.example"
  4162. client_id = "au-sub-client"
  4163. admin_token = await _setup_and_login(async_client, "au_sub_adm", "AuSubAdm1!")
  4164. provider_id = await self._create_provider(async_client, admin_token, issuer, client_id)
  4165. location = await _run_oidc_callback(
  4166. async_client,
  4167. db_session,
  4168. provider_id=provider_id,
  4169. claims={"sub": "abc123xyz"},
  4170. private_pem=private_pem,
  4171. jwks_data=jwks_data,
  4172. issuer=issuer,
  4173. client_id=client_id,
  4174. )
  4175. username = await self._exchange_username(async_client, location)
  4176. assert username == "abc123xyz"
  4177. @pytest.mark.asyncio
  4178. @pytest.mark.integration
  4179. async def test_non_string_preferred_username_falls_through_to_name(
  4180. self, async_client: AsyncClient, db_session: AsyncSession
  4181. ):
  4182. """preferred_username is a list (non-string) → isinstance guard skips it, uses name."""
  4183. private_pem, jwks_data = _make_test_rsa_key()
  4184. issuer = "https://au-nonstr.example"
  4185. client_id = "au-nonstr-client"
  4186. admin_token = await _setup_and_login(async_client, "au_nonstr_adm", "AuNonstrAdm1!")
  4187. provider_id = await self._create_provider(async_client, admin_token, issuer, client_id)
  4188. location = await _run_oidc_callback(
  4189. async_client,
  4190. db_session,
  4191. provider_id=provider_id,
  4192. claims={"sub": "nonstr-sub-2", "preferred_username": ["listval"], "name": "BobJones"},
  4193. private_pem=private_pem,
  4194. jwks_data=jwks_data,
  4195. issuer=issuer,
  4196. client_id=client_id,
  4197. )
  4198. username = await self._exchange_username(async_client, location)
  4199. assert username == "BobJones"
  4200. @pytest.mark.asyncio
  4201. @pytest.mark.integration
  4202. async def test_preferred_username_sanitizes_to_empty_falls_through_to_name(
  4203. self, async_client: AsyncClient, db_session: AsyncSession
  4204. ):
  4205. """preferred_username='!!!' sanitizes to '' → falls through to name claim."""
  4206. private_pem, jwks_data = _make_test_rsa_key()
  4207. issuer = "https://au-empty.example"
  4208. client_id = "au-empty-client"
  4209. admin_token = await _setup_and_login(async_client, "au_empty_adm", "AuEmptyAdm1!")
  4210. provider_id = await self._create_provider(async_client, admin_token, issuer, client_id)
  4211. location = await _run_oidc_callback(
  4212. async_client,
  4213. db_session,
  4214. provider_id=provider_id,
  4215. claims={"sub": "empty-sub-1", "preferred_username": "!!!", "name": "bob"},
  4216. private_pem=private_pem,
  4217. jwks_data=jwks_data,
  4218. issuer=issuer,
  4219. client_id=client_id,
  4220. )
  4221. username = await self._exchange_username(async_client, location)
  4222. assert username == "bob"
  4223. @pytest.mark.asyncio
  4224. @pytest.mark.integration
  4225. async def test_username_collision_appends_counter(self, async_client: AsyncClient, db_session: AsyncSession):
  4226. """When preferred_username 'collider' is already taken, counter suffix is appended."""
  4227. from backend.app.core.auth import get_password_hash
  4228. # Pre-create a user occupying the candidate username
  4229. existing = User(
  4230. username="collider",
  4231. email="collider@example.com",
  4232. password_hash=get_password_hash("irrelevant"),
  4233. role="user",
  4234. is_active=True,
  4235. )
  4236. db_session.add(existing)
  4237. await db_session.commit()
  4238. private_pem, jwks_data = _make_test_rsa_key()
  4239. issuer = "https://au-collision.example"
  4240. client_id = "au-collision-client"
  4241. admin_token = await _setup_and_login(async_client, "au_col_adm", "AuColAdm1!")
  4242. provider_id = await self._create_provider(async_client, admin_token, issuer, client_id)
  4243. location = await _run_oidc_callback(
  4244. async_client,
  4245. db_session,
  4246. provider_id=provider_id,
  4247. claims={"sub": "col-sub-1", "preferred_username": "collider"},
  4248. private_pem=private_pem,
  4249. jwks_data=jwks_data,
  4250. issuer=issuer,
  4251. client_id=client_id,
  4252. )
  4253. username = await self._exchange_username(async_client, location)
  4254. assert username == "collider1"
  4255. # ===========================================================================
  4256. # OIDC auto-create: configurable default group (#1173 Thread 2)
  4257. # ===========================================================================
  4258. class TestOIDCAutoCreateDefaultGroup:
  4259. """Auto-created OIDC users receive the provider's configured default group.
  4260. Resolution order:
  4261. 1. provider.default_group_id (configured)
  4262. 2. "Viewers" system group (fallback when default_group_id is None)
  4263. 3. no group (last resort when both are unavailable)
  4264. All tests are DB-agnostic: they verify group membership via the OIDC
  4265. exchange response, which includes the user's group list.
  4266. """
  4267. @staticmethod
  4268. async def _create_provider(
  4269. async_client: AsyncClient,
  4270. admin_token: str,
  4271. *,
  4272. issuer: str,
  4273. client_id: str,
  4274. default_group_id: int | None = None,
  4275. ) -> int:
  4276. payload: dict = {
  4277. "name": f"DgAutoProvider-{secrets.token_hex(4)}",
  4278. "issuer_url": issuer,
  4279. "client_id": client_id,
  4280. "client_secret": "secret",
  4281. "scopes": "openid profile",
  4282. "is_enabled": True,
  4283. "auto_create_users": True,
  4284. "email_claim": "email",
  4285. "require_email_verified": True,
  4286. }
  4287. if default_group_id is not None:
  4288. payload["default_group_id"] = default_group_id
  4289. resp = await async_client.post(
  4290. "/api/v1/auth/oidc/providers",
  4291. json=payload,
  4292. headers={"Authorization": f"Bearer {admin_token}"},
  4293. )
  4294. assert resp.status_code == 201, resp.text
  4295. return resp.json()["id"]
  4296. @staticmethod
  4297. async def _run_autocreate_and_get_groups(
  4298. async_client: AsyncClient,
  4299. db_session: AsyncSession,
  4300. *,
  4301. provider_id: int,
  4302. sub: str,
  4303. issuer: str,
  4304. client_id: str,
  4305. private_pem: bytes,
  4306. jwks_data: dict,
  4307. ) -> list[str]:
  4308. """Complete OIDC callback + exchange and return the new user's group names."""
  4309. location = await _run_oidc_callback(
  4310. async_client,
  4311. db_session,
  4312. provider_id=provider_id,
  4313. claims={"sub": sub},
  4314. private_pem=private_pem,
  4315. jwks_data=jwks_data,
  4316. issuer=issuer,
  4317. client_id=client_id,
  4318. )
  4319. assert "oidc_token=" in location, f"No oidc_token in redirect: {location}"
  4320. token = location.split("oidc_token=")[1].split("&")[0].split("#")[-1]
  4321. resp = await async_client.post("/api/v1/auth/oidc/exchange", json={"oidc_token": token})
  4322. assert resp.status_code == 200, resp.text
  4323. return [g["name"] for g in resp.json()["user"]["groups"]]
  4324. # ── tests ────────────────────────────────────────────────────────────────
  4325. @pytest.mark.asyncio
  4326. @pytest.mark.integration
  4327. async def test_configured_group_assigned_to_auto_created_user(
  4328. self, async_client: AsyncClient, db_session: AsyncSession
  4329. ):
  4330. """Auto-created user is placed in the provider's configured default_group_id."""
  4331. from sqlalchemy import select
  4332. from backend.app.models.group import Group
  4333. private_pem, jwks_data = _make_test_rsa_key()
  4334. issuer = "https://dg-configured.example"
  4335. client_id = "dg-configured-client"
  4336. admin_token = await _setup_and_login(async_client, "dg_cfg_adm", "DgCfgAdm1!")
  4337. grp_result = await db_session.execute(select(Group).where(Group.name == "Operators"))
  4338. operators = grp_result.scalar_one()
  4339. provider_id = await self._create_provider(
  4340. async_client,
  4341. admin_token,
  4342. issuer=issuer,
  4343. client_id=client_id,
  4344. default_group_id=operators.id,
  4345. )
  4346. group_names = await self._run_autocreate_and_get_groups(
  4347. async_client,
  4348. db_session,
  4349. provider_id=provider_id,
  4350. sub="dg-cfg-sub-1",
  4351. issuer=issuer,
  4352. client_id=client_id,
  4353. private_pem=private_pem,
  4354. jwks_data=jwks_data,
  4355. )
  4356. assert "Operators" in group_names, f"Expected Operators, got {group_names}"
  4357. assert "Viewers" not in group_names
  4358. @pytest.mark.asyncio
  4359. @pytest.mark.integration
  4360. async def test_null_default_group_id_falls_back_to_viewers(
  4361. self, async_client: AsyncClient, db_session: AsyncSession
  4362. ):
  4363. """When default_group_id is None, auto-created user falls back to Viewers."""
  4364. private_pem, jwks_data = _make_test_rsa_key()
  4365. issuer = "https://dg-null.example"
  4366. client_id = "dg-null-client"
  4367. admin_token = await _setup_and_login(async_client, "dg_null_adm", "DgNullAdm1!")
  4368. provider_id = await self._create_provider(
  4369. async_client,
  4370. admin_token,
  4371. issuer=issuer,
  4372. client_id=client_id,
  4373. )
  4374. group_names = await self._run_autocreate_and_get_groups(
  4375. async_client,
  4376. db_session,
  4377. provider_id=provider_id,
  4378. sub="dg-null-sub-1",
  4379. issuer=issuer,
  4380. client_id=client_id,
  4381. private_pem=private_pem,
  4382. jwks_data=jwks_data,
  4383. )
  4384. assert "Viewers" in group_names, f"Expected Viewers, got {group_names}"
  4385. @pytest.mark.asyncio
  4386. @pytest.mark.integration
  4387. async def test_dangling_default_group_id_falls_back_to_viewers(
  4388. self, async_client: AsyncClient, db_session: AsyncSession
  4389. ):
  4390. """When configured group is deleted, auto-created user falls back to Viewers.
  4391. SQLite does not enforce FK ON DELETE SET NULL (no PRAGMA foreign_keys=ON),
  4392. so provider.default_group_id may point to a deleted group. The runtime
  4393. resolution chain must handle this and fall back to Viewers.
  4394. """
  4395. from sqlalchemy import delete as sa_delete, select
  4396. from backend.app.models.group import Group
  4397. private_pem, jwks_data = _make_test_rsa_key()
  4398. issuer = "https://dg-dangling.example"
  4399. client_id = "dg-dangling-client"
  4400. admin_token = await _setup_and_login(async_client, "dg_dangle_adm", "DgDangleAdm1!")
  4401. # Create a temporary group and use it as default_group_id
  4402. temp_group = Group(name="TempGroup-DgDangle", permissions=[])
  4403. db_session.add(temp_group)
  4404. await db_session.commit()
  4405. await db_session.refresh(temp_group)
  4406. temp_group_id = temp_group.id
  4407. provider_id = await self._create_provider(
  4408. async_client,
  4409. admin_token,
  4410. issuer=issuer,
  4411. client_id=client_id,
  4412. default_group_id=temp_group_id,
  4413. )
  4414. # Delete the group — simulates dangling FK (especially on SQLite)
  4415. await db_session.execute(sa_delete(Group).where(Group.id == temp_group_id))
  4416. await db_session.commit()
  4417. group_names = await self._run_autocreate_and_get_groups(
  4418. async_client,
  4419. db_session,
  4420. provider_id=provider_id,
  4421. sub="dg-dangle-sub-1",
  4422. issuer=issuer,
  4423. client_id=client_id,
  4424. private_pem=private_pem,
  4425. jwks_data=jwks_data,
  4426. )
  4427. assert "Viewers" in group_names, f"Expected Viewers fallback, got {group_names}"
  4428. @pytest.mark.asyncio
  4429. @pytest.mark.integration
  4430. async def test_administrators_group_can_be_set_as_default(
  4431. self, async_client: AsyncClient, db_session: AsyncSession
  4432. ):
  4433. """Operators can configure Administrators as the default group (e.g. single-tenant IdP)."""
  4434. from sqlalchemy import select
  4435. from backend.app.models.group import Group
  4436. private_pem, jwks_data = _make_test_rsa_key()
  4437. issuer = "https://dg-admin.example"
  4438. client_id = "dg-admin-client"
  4439. admin_token = await _setup_and_login(async_client, "dg_admgrp_adm", "DgAdmgrpAdm1!")
  4440. grp_result = await db_session.execute(select(Group).where(Group.name == "Administrators"))
  4441. administrators = grp_result.scalar_one()
  4442. provider_id = await self._create_provider(
  4443. async_client,
  4444. admin_token,
  4445. issuer=issuer,
  4446. client_id=client_id,
  4447. default_group_id=administrators.id,
  4448. )
  4449. group_names = await self._run_autocreate_and_get_groups(
  4450. async_client,
  4451. db_session,
  4452. provider_id=provider_id,
  4453. sub="dg-admin-sub-1",
  4454. issuer=issuer,
  4455. client_id=client_id,
  4456. private_pem=private_pem,
  4457. jwks_data=jwks_data,
  4458. )
  4459. assert "Administrators" in group_names, f"Expected Administrators, got {group_names}"
  4460. class TestListOidcLinksDefensiveProviderNull:
  4461. """list_oidc_links must not crash if a link's provider is orphaned on SQLite.
  4462. PR for #1285 added a defensive null-check at mfa.py:1871 so the endpoint
  4463. returns ``provider_name="<deleted>"`` for orphan links instead of raising
  4464. AttributeError when accessing ``link.provider.name``. This scenario is
  4465. only reachable on SQLite (PRAGMA foreign_keys=OFF) when an OIDCProvider
  4466. row is removed without the ORM cascade running.
  4467. """
  4468. @pytest.mark.sqlite_only # a dangling reference needs foreign keys off
  4469. @pytest.mark.asyncio
  4470. @pytest.mark.integration
  4471. async def test_list_oidc_links_returns_deleted_marker_for_orphan_provider(
  4472. self, async_client: AsyncClient, db_session: AsyncSession
  4473. ):
  4474. from sqlalchemy import select
  4475. from backend.app.models.oidc_provider import UserOIDCLink
  4476. admin_token = await _setup_and_login(async_client, "linkorphan_adm", "LinkOrphanAdm1!")
  4477. admin_row = await db_session.execute(select(User).where(User.username == "linkorphan_adm"))
  4478. admin_user = admin_row.scalar_one()
  4479. # Orphan link: provider_id=99999 deliberately points at no row.
  4480. db_session.add(
  4481. UserOIDCLink(
  4482. user_id=admin_user.id,
  4483. provider_id=99999,
  4484. provider_user_id="orphan-sub",
  4485. provider_email="orphan@example.com",
  4486. )
  4487. )
  4488. await db_session.commit()
  4489. resp = await async_client.get(
  4490. "/api/v1/auth/oidc/links",
  4491. headers=_auth_header(admin_token),
  4492. )
  4493. assert resp.status_code == 200, resp.text
  4494. links = resp.json()
  4495. assert len(links) == 1
  4496. # The fix: orphan provider_id no longer crashes — returns "<deleted>" instead.
  4497. assert links[0]["provider_name"] == "<deleted>", (
  4498. f"Expected '<deleted>' fallback for orphan provider, got {links[0]['provider_name']!r}"
  4499. )
  4500. assert links[0]["provider_id"] == 99999